Make settings restore crash-safe

This commit is contained in:
Gabriel Brown
2026-08-18 02:22:23 -04:00
parent 172b099a04
commit 83391e0453
5 changed files with 880 additions and 387 deletions
@@ -1,391 +1,625 @@
#!/usr/bin/env bash #!/usr/bin/env python3
# Versioned snapshots of Panama's durable settings stores. """Crash-safe snapshots of Panama's desktop and Home preference stores.
#
# New snapshots are a small envelope which records both data and absence for
# the schema store and Home favourites. Settings-only snapshots written by an
# older Panama remain restorable; because they carry no Home metadata, they
# deliberately leave current Home state alone.
set -euo pipefail A restore is a two-file transaction. Its fixed journal and artifacts live at
`$XDG_STATE_HOME/panama/transactions/settings-restore`; they contain no
caller-provided paths. The journal is fsynced before either destination changes
and is removed only after both replacements are durable. Every invocation
recovers an incomplete transaction before doing any other work.
"""
config_root="${XDG_CONFIG_HOME:-$HOME/.config}" from __future__ import annotations
state_root="${XDG_STATE_HOME:-$HOME/.local/state}"
settings="$config_root/panama/settings.json"
home="$state_root/panama/panama-home.json"
backup_dir="$state_root/panama/backups"
keep=15
home_json_filter='type == "object"
and (.initialized | type == "boolean")
and (.favorites | type == "array")
and all(.favorites[];
type == "object"
and (.id | type == "string" and test("^light\\.[a-z0-9_]+$"))
and (.alias | type == "string"))
and ((.initialized == true) or (.favorites | length == 0))
and ((.favorites | map(.id) | unique | length) == (.favorites | length))'
fail() { import json
printf '%s\n' "$1" >&2 import os
exit 1 import re
} import stat
import sys
import tempfile
import time
import fcntl
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any, Iterator, NoReturn
store_present() {
[[ -e "$1" || -L "$1" ]]
}
store_valid() { HOME = Path(os.environ.get("HOME", str(Path.home())))
local path="$1" CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
[[ -f "$path" && ! -L "$path" && -r "$path" ]] \ STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
&& jq -e 'type == "object"' "$path" >/dev/null 2>&1 SETTINGS = CONFIG_ROOT / "panama/settings.json"
} HOME_STATE = STATE_ROOT / "panama/panama-home.json"
BACKUP_DIR = STATE_ROOT / "panama/backups"
TRANSACTION_PARENT = STATE_ROOT / "panama/transactions"
TRANSACTION_DIR = TRANSACTION_PARENT / "settings-restore"
JOURNAL = TRANSACTION_DIR / "journal.json"
LOCK_FILE = TRANSACTION_PARENT / "settings-backup.lock"
KEEP = 15
SNAPSHOT_RE = re.compile(r"^settings-[0-9]{8}-[0-9]{9}\.json$")
ENTITY_RE = re.compile(r"^light\.[a-z0-9_]+$")
home_store_valid() {
local path="$1"
store_valid "$path" && jq -e "$home_json_filter" "$path" >/dev/null 2>&1
}
validate_store() { class BackupError(RuntimeError):
local path="$1" pass
local label="$2"
store_present "$path" || return 1
[[ ! -L "$path" ]] || fail "$label is a symbolic link and cannot be backed up safely."
[[ -f "$path" && -r "$path" ]] || fail "$label is not a readable file."
jq -e 'type == "object"' "$path" >/dev/null 2>&1 \
|| fail "$label is not valid settings JSON."
}
validate_home_store() {
validate_store "$1" "$2"
jq -e "$home_json_filter" "$1" >/dev/null 2>&1 \
|| fail "$2 does not contain valid Home favourites."
}
# The live HomePreferences store still lives in Quickshell's private state def fail(message: str) -> NoReturn:
# directory. SettingsBackup passes its public values as one argv element so the raise BackupError(message)
# canonical Panama state file is current before save. No value is evaluated or
# interpolated into a command.
write_live_home() {
local json="$1"
local directory
local temp
jq -e "$home_json_filter" <<<"$json" >/dev/null 2>&1 \
|| fail "The live Home state is not valid."
[[ ! -L "$home" ]] \
|| fail "The current Home state file is a symbolic link and cannot be replaced safely."
directory="$(dirname "$home")"
mkdir -p "$directory"
temp="$(mktemp "$directory/.home-save.XXXXXX")"
chmod 600 "$temp"
jq '.' <<<"$json" >"$temp"
mv -- "$temp" "$home"
}
next_snapshot_path() { def fsync_directory(path: Path) -> None:
local stamp descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
local candidate try:
while true; do os.fsync(descriptor)
stamp="$(date +%Y%m%d-%H%M%S%3N)" finally:
candidate="$backup_dir/settings-$stamp.json" os.close(descriptor)
if [[ ! -e "$candidate" && ! -L "$candidate" ]]; then
snapshot_stamp="$stamp"
snapshot_path="$candidate"
return
fi
sleep 0.002
done
}
prune_snapshots() {
local -a entries=() def ensure_directory(path: Path) -> None:
local index path.mkdir(parents=True, exist_ok=True)
local path if path.is_symlink() or not path.is_dir():
mapfile -d '' entries < <( fail(f"{path} is not a safe directory.")
find "$backup_dir" -maxdepth 1 -type f \
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
-printf '%T@ %p\0' | sort -zrn @contextmanager
def process_lock() -> Iterator[None]:
ensure_directory(TRANSACTION_PARENT)
if LOCK_FILE.is_symlink():
fail("The settings transaction lock is a symbolic link.")
descriptor = os.open(
LOCK_FILE,
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
0o600,
) )
for ((index = keep; index < ${#entries[@]}; index++)); do try:
path="${entries[$index]#* }" os.fchmod(descriptor, 0o600)
rm -f -- "$path" fcntl.flock(descriptor, fcntl.LOCK_EX)
done yield
} finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
# Writes the current stores after callers have decided whether invalid or
# absent state should be fatal. The destination is created in the backup
# directory and renamed into place, so list/restore never observe half a JSON
# document.
write_snapshot() {
local desktop_present=false
local home_present=false
local temp
store_present "$settings" && desktop_present=true def is_present(path: Path) -> bool:
store_present "$home" && home_present=true return path.exists() or path.is_symlink()
[[ "$desktop_present" == true || "$home_present" == true ]] \
|| return 1
mkdir -p "$backup_dir"
next_snapshot_path
temp="$(mktemp "$backup_dir/.settings-snapshot.XXXXXX")"
chmod 600 "$temp"
if [[ "$desktop_present" == true && "$home_present" == true ]]; then def require_regular(path: Path, label: str) -> None:
jq -n --slurpfile desktop "$settings" --slurpfile home "$home" '{ if path.is_symlink():
version: 2, fail(f"{label} is a symbolic link and cannot be used safely.")
desktop: { present: true, data: $desktop[0] }, try:
home: { present: true, data: $home[0] } mode = path.stat().st_mode
}' >"$temp" except FileNotFoundError:
elif [[ "$desktop_present" == true ]]; then fail(f"{label} is missing.")
jq -n --slurpfile desktop "$settings" '{ if not stat.S_ISREG(mode):
version: 2, fail(f"{label} is not a regular file.")
desktop: { present: true, data: $desktop[0] },
home: { present: false }
}' >"$temp"
else
jq -n --slurpfile home "$home" '{
version: 2,
desktop: { present: false },
home: { present: true, data: $home[0] }
}' >"$temp"
fi
mv -- "$temp" "$snapshot_path"
prune_snapshots
}
snapshot_source() { def read_json(path: Path, label: str) -> dict[str, Any]:
local name="$1" require_regular(path, label)
local candidate="$backup_dir/$name" try:
local canonical_dir value = json.loads(path.read_text(encoding="utf-8"))
local canonical_file except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise BackupError(f"{label} is not valid JSON.") from error
if not isinstance(value, dict):
fail(f"{label} is not a JSON object.")
return value
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] \
|| fail "Not a snapshot name."
[[ -f "$candidate" && ! -L "$candidate" && -r "$candidate" ]] \
|| fail "That snapshot is missing."
canonical_dir="$(realpath -e -- "$backup_dir")" def valid_home(value: Any) -> bool:
canonical_file="$(realpath -e -- "$candidate")" if not isinstance(value, dict):
[[ "$canonical_file" == "$canonical_dir/"* ]] \ return False
|| fail "That snapshot is outside the backup directory." initialized = value.get("initialized")
favorites = value.get("favorites")
if not isinstance(initialized, bool) or not isinstance(favorites, list):
return False
if not initialized and favorites:
return False
seen: set[str] = set()
for favorite in favorites:
if not isinstance(favorite, dict):
return False
entity_id = favorite.get("id")
alias = favorite.get("alias")
if (
not isinstance(entity_id, str)
or ENTITY_RE.fullmatch(entity_id) is None
or not isinstance(alias, str)
or entity_id in seen
):
return False
seen.add(entity_id)
return True
printf '%s\n' "$candidate"
}
validate_snapshot() { def validate_home(value: Any, label: str) -> dict[str, Any]:
local source_file="$1" if not valid_home(value):
fail(f"{label} does not contain valid Home favourites.")
return value
jq -e 'type == "object"' "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot is not valid JSON."
if jq -e 'has("version")' "$source_file" >/dev/null 2>&1; then def json_bytes(value: Any) -> bytes:
jq -e ' return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
.version == 2
and (.desktop | type == "object")
and (.desktop.present | type == "boolean")
and ((.desktop.present == false) or (.desktop.data | type == "object"))
and (.home | type == "object")
and (.home.present | type == "boolean")
and ((.home.present == false) or (.home.data | type == "object"))
' "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot uses an unsupported format."
if jq -e '.home.present' "$source_file" >/dev/null 2>&1; then
jq -e ".home.data | $home_json_filter" "$source_file" >/dev/null 2>&1 \
|| fail "That snapshot contains invalid Home favourites."
fi
snapshot_format="versioned"
else
snapshot_format="legacy"
fi
}
stage_json() {
local source_file="$1"
local filter="$2"
local directory="$3"
local template="$4"
local staged
mkdir -p "$directory" def atomic_write_bytes(path: Path, content: bytes) -> None:
staged="$(mktemp "$directory/$template.XXXXXX")" ensure_directory(path.parent)
chmod 600 "$staged" if path.is_symlink():
jq -e "$filter" "$source_file" >"$staged" fail(f"{path} is a symbolic link and cannot be replaced safely.")
printf '%s\n' "$staged" descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
} temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
fsync_directory(path.parent)
finally:
if temporary.exists() or temporary.is_symlink():
temporary.unlink()
backup_current_file() {
local target="$1"
local directory="$2"
local template="$3"
if ! store_present "$target"; then def atomic_write_json(path: Path, value: Any) -> None:
printf '\n' atomic_write_bytes(path, json_bytes(value))
def durable_remove(path: Path) -> None:
if path.exists() or path.is_symlink():
path.unlink()
fsync_directory(path.parent)
def transaction_path(name: str) -> Path:
if name not in {
"journal.json",
"desktop.old",
"desktop.new",
"home.old",
"home.new",
}:
fail("The restore transaction contains an unknown artifact name.")
path = TRANSACTION_DIR / name
resolved_parent = path.parent.resolve(strict=False)
if resolved_parent != TRANSACTION_DIR.resolve(strict=False):
fail("The restore transaction escaped its contained state directory.")
return path
def clean_transaction_artifacts() -> None:
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return return
fi if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
[[ -f "$target" && ! -L "$target" ]] \ fail("The restore transaction path is not a safe directory.")
|| fail "A settings target is not a regular file." for child in list(TRANSACTION_DIR.iterdir()):
local rollback if child.name not in {
rollback="$(mktemp "$directory/$template.XXXXXX")" "journal.json",
chmod 600 "$rollback" "desktop.old",
cp -- "$target" "$rollback" "desktop.new",
printf '%s\n' "$rollback" "home.old",
} "home.new",
} and not child.name.startswith(".journal.json."):
fail("The restore transaction directory contains an unknown artifact.")
if child.is_dir() and not child.is_symlink():
fail("The restore transaction contains an unexpected directory.")
child.unlink()
fsync_directory(TRANSACTION_DIR)
TRANSACTION_DIR.rmdir()
fsync_directory(TRANSACTION_PARENT)
restore_target() {
local target="$1"
local present="$2"
local staged="$3"
if [[ "$present" == true ]]; then def clean_stale_atomic_files() -> None:
mv -- "$staged" "$target" locations = (
else (SETTINGS.parent, (".settings.json.",)),
rm -f -- "$target" (HOME_STATE.parent, (".panama-home.json.",)),
fi (BACKUP_DIR, (".settings-",)),
}
case "${1:-list}" in
save)
desktop_present=false
home_present=false
if store_present "$settings"; then
validate_store "$settings" "The current settings file"
desktop_present=true
fi
if [[ $# -ge 2 ]]; then
write_live_home "$2"
fi
if store_present "$home"; then
validate_home_store "$home" "The current Home state file"
home_present=true
fi
[[ "$desktop_present" == true || "$home_present" == true ]] \
|| fail "No Panama settings exist to back up."
write_snapshot
printf '{"saved":"settings-%s.json"}\n' "$snapshot_stamp"
;;
list)
mkdir -p "$backup_dir"
first=true
printf '['
while IFS= read -r -d '' entry; do
file="${entry#* }"
name="$(basename -- "$file")"
raw="${name#settings-}"
raw="${raw%.json}"
pretty="${raw:0:4}-${raw:4:2}-${raw:6:2} ${raw:9:2}:${raw:11:2}:${raw:13:2}"
if jq -e '.version == 2' "$file" >/dev/null 2>&1; then
keys="$(jq -r 'if .desktop.present then (.desktop.data | keys | length) else 0 end' "$file" 2>/dev/null || printf 0)"
else
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
fi
[[ "$first" == true ]] || printf ','
first=false
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
done < <(
find "$backup_dir" -maxdepth 1 -type f \
-name 'settings-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].json' \
-printf '%T@ %p\0' | sort -zrn
) )
printf ']\n' for directory, prefixes in locations:
;; if not directory.exists():
continue
if directory.is_symlink() or not directory.is_dir():
fail(f"{directory} is not a safe directory.")
changed = False
for child in directory.iterdir():
if not any(child.name.startswith(prefix) for prefix in prefixes):
continue
# Only Panama's hidden atomic-write names are eligible. A matching
# directory is unexpected and is never recursively removed.
if child.is_dir() and not child.is_symlink():
fail("A stale settings temporary path is an unexpected directory.")
child.unlink()
changed = True
if changed:
fsync_directory(directory)
restore)
name="${2:-}"
[[ -n "$name" ]] || fail "Which snapshot?"
source_file="$(snapshot_source "$name")"
validate_snapshot "$source_file"
desktop_present=true def validate_journal_side(value: Any) -> dict[str, bool]:
home_action="preserve" if not isinstance(value, dict):
if [[ "$snapshot_format" == "versioned" ]]; then fail("The restore journal is malformed.")
desktop_present="$(jq -r '.desktop.present' "$source_file")" if set(value) != {"touch", "oldPresent", "newPresent"}:
home_action="$(jq -r 'if .home.present then "present" else "absent" end' "$source_file")" fail("The restore journal is malformed.")
if [[ "$desktop_present" == true ]]; then if not all(isinstance(value[key], bool) for key in value):
desktop_stage="$(stage_json "$source_file" '.desktop.data' "$(dirname "$settings")" '.settings-restore')" fail("The restore journal is malformed.")
else return value
mkdir -p "$(dirname "$settings")"
desktop_stage=""
fi
if [[ "$home_action" == "present" ]]; then
home_stage="$(stage_json "$source_file" '.home.data' "$(dirname "$home")" '.home-restore')"
else
mkdir -p "$(dirname "$home")"
home_stage=""
fi
else
desktop_stage="$(stage_json "$source_file" '.' "$(dirname "$settings")" '.settings-restore')"
mkdir -p "$(dirname "$home")"
home_stage=""
fi
# Preserve the replaced state as an undo snapshot only when every
# existing store is valid. A corrupt store must not prevent recovery,
# but it is not useful as a future restore point either.
if { ! store_present "$settings" || store_valid "$settings"; } \
&& { ! store_present "$home" || home_store_valid "$home"; }; then
write_snapshot >/dev/null 2>&1 || true
fi
settings_dir="$(dirname "$settings")" def read_journal() -> dict[str, Any]:
home_dir="$(dirname "$home")" value = read_json(JOURNAL, "The restore journal")
settings_rollback="$(backup_current_file "$settings" "$settings_dir" '.settings-rollback')" if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
home_rollback="$(backup_current_file "$home" "$home_dir" '.home-rollback')" fail("The restore journal uses an unsupported format.")
rollback_needed=true return {
"version": 1,
rollback() { "desktop": validate_journal_side(value.get("desktop")),
if [[ "$settings_rollback" != "" ]]; then "home": validate_journal_side(value.get("home")),
mv -f -- "$settings_rollback" "$settings"
else
rm -f -- "$settings"
fi
if [[ "$home_action" != "preserve" ]]; then
if [[ "$home_rollback" != "" ]]; then
mv -f -- "$home_rollback" "$home"
else
rm -f -- "$home"
fi
fi
} }
cleanup_restore() {
local status=$? def target_for(store: str) -> Path:
if [[ "$rollback_needed" == true ]]; then if store == "desktop":
rollback || true return SETTINGS
fi if store == "home":
[[ "${desktop_stage:-}" == "" ]] || rm -f -- "$desktop_stage" return HOME_STATE
[[ "${home_stage:-}" == "" ]] || rm -f -- "$home_stage" fail("The restore journal names an unknown store.")
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
exit "$status" def apply_artifact(store: str, generation: str, present: bool) -> None:
target = target_for(store)
if present:
artifact = transaction_path(f"{store}.{generation}")
require_regular(artifact, "A restore transaction artifact")
atomic_write_bytes(target, artifact.read_bytes())
else:
ensure_directory(target.parent)
if target.is_symlink():
fail(f"{target} is a symbolic link and cannot be replaced safely.")
durable_remove(target)
def recover_transaction() -> None:
ensure_directory(TRANSACTION_PARENT)
if not TRANSACTION_DIR.exists() and not TRANSACTION_DIR.is_symlink():
return
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
fail("The restore transaction path is not a safe directory.")
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
return
journal = read_journal()
for store in ("desktop", "home"):
side = journal[store]
if side["touch"]:
apply_artifact(store, "old", side["oldPresent"])
# Journal absence is the durable commit marker for recovery too. If a
# second power loss occurs above, the journal remains and recovery retries.
durable_remove(JOURNAL)
clean_transaction_artifacts()
def is_v2_side(value: Any) -> bool:
return (
isinstance(value, dict)
and isinstance(value.get("present"), bool)
and (not value["present"] or isinstance(value.get("data"), dict))
)
def is_v2_envelope(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("version") == 2
and is_v2_side(value.get("desktop"))
and is_v2_side(value.get("home"))
)
def validate_snapshot(value: dict[str, Any]) -> tuple[str, dict[str, Any]]:
if not is_v2_envelope(value):
return "legacy", value
if value["home"]["present"]:
validate_home(value["home"]["data"], "That snapshot")
return "versioned", value
def current_store(path: Path, label: str, *, home_store: bool = False) -> tuple[bool, Any]:
if not is_present(path):
return False, None
value = read_json(path, label)
if home_store:
validate_home(value, label)
return True, value
def next_snapshot_path() -> Path:
ensure_directory(BACKUP_DIR)
while True:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S%f")[:18]
candidate = BACKUP_DIR / f"settings-{stamp}.json"
if not is_present(candidate):
return candidate
time.sleep(0.002)
def prune_snapshots() -> None:
snapshots = sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
for old in snapshots[KEEP:]:
durable_remove(old)
def save_snapshot(*, require_any: bool, validate: bool) -> Path | None:
try:
desktop_present, desktop = current_store(
SETTINGS, "The current settings file"
)
home_present, home = current_store(
HOME_STATE, "The current Home state file", home_store=True
)
except BackupError:
if validate:
raise
return None
if not desktop_present and not home_present:
if require_any:
fail("No Panama settings exist to back up.")
return None
envelope: dict[str, Any] = {
"version": 2,
"desktop": {"present": desktop_present},
"home": {"present": home_present},
} }
trap cleanup_restore EXIT if desktop_present:
envelope["desktop"]["data"] = desktop
if home_present:
envelope["home"]["data"] = home
restore_target "$settings" "$desktop_present" "$desktop_stage" destination = next_snapshot_path()
if [[ "$home_action" != "preserve" ]]; then atomic_write_json(destination, envelope)
restore_target "$home" "$([[ "$home_action" == "present" ]] && printf true || printf false)" "$home_stage" prune_snapshots()
fi return destination
rollback_needed=false
trap - EXIT
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
if [[ "$home_action" == "present" ]]; then
jq -cn --arg restored "$name" --slurpfile home "$home" \
'{restored: $restored, home: {present: true, data: $home[0]}}'
elif [[ "$home_action" == "absent" ]]; then
jq -cn --arg restored "$name" \
'{restored: $restored, home: {present: false}}'
else
jq -cn --arg restored "$name" \
'{restored: $restored, home: {preserve: true}}'
fi
;;
*) def snapshot_source(name: str) -> Path:
fail "usage: panama-settings-backup [save|list|restore <name>]" if SNAPSHOT_RE.fullmatch(name) is None:
;; fail("Not a snapshot name.")
esac ensure_directory(BACKUP_DIR)
candidate = BACKUP_DIR / name
require_regular(candidate, "That snapshot")
if candidate.resolve(strict=True).parent != BACKUP_DIR.resolve(strict=True):
fail("That snapshot is outside the backup directory.")
return candidate
def stage_artifact(name: str, content: bytes) -> None:
path = transaction_path(name)
if path.exists() or path.is_symlink():
fail("A stale restore transaction artifact was not recovered.")
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
finally:
# fdopen owns the descriptor after construction.
pass
fsync_directory(TRANSACTION_DIR)
def capture_old(store: str, target: Path) -> bool:
if not is_present(target):
return False
require_regular(target, f"The current {store} settings file")
stage_artifact(f"{store}.old", target.read_bytes())
return True
def prepare_transaction(
desktop_present: bool,
desktop_data: dict[str, Any] | None,
home_touch: bool,
home_present: bool,
home_data: dict[str, Any] | None,
) -> dict[str, Any]:
# Cleanup is active before the first artifact is created. A pre-journal
# error removes every staged/rollback file; a process death is recovered as
# stale preparation by the next invocation.
ensure_directory(TRANSACTION_PARENT)
clean_transaction_artifacts()
ensure_directory(TRANSACTION_DIR)
fsync_directory(TRANSACTION_PARENT)
try:
desktop_old = capture_old("desktop", SETTINGS)
if desktop_present:
stage_artifact("desktop.new", json_bytes(desktop_data))
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_FAIL") == "after-desktop-stage":
fail("Injected failure after desktop staging.")
home_old = capture_old("home", HOME_STATE) if home_touch else False
if home_touch and home_present:
stage_artifact("home.new", json_bytes(home_data))
journal = {
"version": 1,
"desktop": {
"touch": True,
"oldPresent": desktop_old,
"newPresent": desktop_present,
},
"home": {
"touch": home_touch,
"oldPresent": home_old,
"newPresent": home_present,
},
}
atomic_write_json(JOURNAL, journal)
return journal
except BaseException:
# SIGKILL/os._exit bypass this block by design; the next invocation
# cleans a pre-journal directory or recovers a journalled transaction.
if not JOURNAL.exists() and not JOURNAL.is_symlink():
clean_transaction_artifacts()
raise
def commit_restore(journal: dict[str, Any]) -> None:
try:
desktop = journal["desktop"]
apply_artifact("desktop", "new", desktop["newPresent"])
if os.environ.get("PANAMA_SETTINGS_BACKUP_TEST_CRASH") == "after-desktop":
os._exit(86)
home = journal["home"]
if home["touch"]:
apply_artifact("home", "new", home["newPresent"])
# Both targets and their parent directories are durable. Removing and
# fsyncing the journal is the transaction's commit record.
durable_remove(JOURNAL)
clean_transaction_artifacts()
except BaseException:
# Ordinary failures roll back immediately. Process death leaves the
# journal in place and takes this same path on the next invocation.
recover_transaction()
raise
def write_live_home(text: str) -> None:
try:
value = json.loads(text)
except json.JSONDecodeError as error:
raise BackupError("The live Home state is not valid JSON.") from error
validate_home(value, "The live Home state")
atomic_write_json(HOME_STATE, value)
def command_save(arguments: list[str]) -> None:
if arguments:
write_live_home(arguments[0])
destination = save_snapshot(require_any=True, validate=True)
assert destination is not None
print(json.dumps({"saved": destination.name}, separators=(",", ":")))
def snapshot_files() -> list[Path]:
ensure_directory(BACKUP_DIR)
return sorted(
(
path
for path in BACKUP_DIR.iterdir()
if SNAPSHOT_RE.fullmatch(path.name)
and path.is_file()
and not path.is_symlink()
),
key=lambda path: path.stat().st_mtime_ns,
reverse=True,
)
def command_list() -> None:
output: list[dict[str, Any]] = []
for path in snapshot_files():
try:
value = read_json(path, "A snapshot")
if is_v2_envelope(value):
desktop = value["desktop"]
keys = len(desktop["data"]) if desktop["present"] else 0
else:
keys = len(value)
except BackupError:
keys = 0
raw = path.name.removeprefix("settings-").removesuffix(".json")
pretty = (
f"{raw[0:4]}-{raw[4:6]}-{raw[6:8]} "
f"{raw[9:11]}:{raw[11:13]}:{raw[13:15]}"
)
output.append({"name": path.name, "when": pretty, "keys": keys})
print(json.dumps(output, separators=(",", ":")))
def command_restore(arguments: list[str]) -> None:
if not arguments:
fail("Which snapshot?")
name = arguments[0]
source = snapshot_source(name)
snapshot = read_json(source, "That snapshot")
snapshot_format, value = validate_snapshot(snapshot)
if snapshot_format == "versioned":
desktop_present = value["desktop"]["present"]
desktop_data = value["desktop"].get("data")
home_touch = True
home_present = value["home"]["present"]
home_data = value["home"].get("data")
else:
desktop_present = True
desktop_data = value
home_touch = False
home_present = False
home_data = None
# Restoring remains undoable, but a corrupt current file must not prevent a
# known-good snapshot from recovering the desktop.
save_snapshot(require_any=False, validate=False)
journal = prepare_transaction(
desktop_present,
desktop_data,
home_touch,
home_present,
home_data,
)
commit_restore(journal)
if not home_touch:
home_result: dict[str, Any] = {"preserve": True}
elif home_present:
home_result = {"present": True, "data": home_data}
else:
home_result = {"present": False}
print(
json.dumps(
{"restored": name, "home": home_result},
separators=(",", ":"),
)
)
def main() -> None:
with process_lock():
clean_stale_atomic_files()
recover_transaction()
command = sys.argv[1] if len(sys.argv) > 1 else "list"
arguments = sys.argv[2:]
if command == "save":
command_save(arguments)
elif command == "list":
command_list()
elif command == "restore":
command_restore(arguments)
else:
fail("usage: panama-settings-backup [save|list|restore <name>]")
if __name__ == "__main__":
try:
main()
except BackupError as error:
print(str(error), file=sys.stderr)
raise SystemExit(1) from error
except OSError as error:
print("The settings backup could not access its state files.", file=sys.stderr)
raise SystemExit(1) from error
@@ -25,6 +25,29 @@ Singleton {
property string lastError: "" property string lastError: ""
property string lastAction: "" property string lastAction: ""
// Narrow service boundaries keep restore sequencing explicit and make it
// possible to verify the real handler in an isolated shell without ever
// calling the daily-driver compositor or wallpaper services.
property var readHomeState: function() {
return {
initialized: HomePreferences.initialized,
favorites: HomePreferences.favorites
};
}
property var resetHome: function() { HomePreferences.resetHomeDefaults(); }
property var initializeHome: function(ids) { HomePreferences.initialize(ids); }
property var aliasHome: function(id, alias) { HomePreferences.setAlias(id, alias); }
property var reloadDesktop: function() { DesktopPreferences.reload(); }
property var applyCompositor: function() { SystemSettings.applyPersistedDisplayPolicy(); }
property var reloadKeybinds: function() { Keybinds.applyReload(); }
property var keybindsReloading: function() { return Keybinds.reloading; }
property var systemBusy: function() { return SystemSettings.busy; }
property var currentWallpaper: function() {
return String(DesktopPreferences.get("wallpaperPath") ?? "");
}
property var applyWallpaper: function(path) { Wallpaper.set(path); }
property var reloadShell: function() { Quickshell.reload(false); }
readonly property bool busy: listQuery.running || actionRun.running readonly property bool busy: listQuery.running || actionRun.running
|| applyRestoredState.running || settleReload.running || applyRestoredState.running || settleReload.running
@@ -62,12 +85,10 @@ Singleton {
} }
root.lastAction = actionRun.restoring ? "restored" : "saved"; root.lastAction = actionRun.restoring ? "restored" : "saved";
if (actionRun.restoring) { if (actionRun.restoring) {
const homeReloaded = root.reloadHomeState(actionRun.outputText); const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
root.lastError = homeReloaded root.lastError = homeReloaded
? "" ? ""
: "Desktop settings were restored, but Home favourites could not be reloaded."; : "Desktop settings were restored, but Home favourites could not be reloaded.";
DesktopPreferences.reload();
applyRestoredState.restart();
} else } else
root.lastError = ""; root.lastError = "";
root.refresh(); root.refresh();
@@ -82,9 +103,9 @@ Singleton {
// DesktopPreferences.reload() invalidates reactive shell bindings. // DesktopPreferences.reload() invalidates reactive shell bindings.
// These services also own state outside QML and need an explicit // These services also own state outside QML and need an explicit
// replay: compositor options, Lua-generated binds, and hyprpaper. // replay: compositor options, Lua-generated binds, and hyprpaper.
SystemSettings.applyPersistedDisplayPolicy(); root.applyCompositor();
Keybinds.applyReload(); root.reloadKeybinds();
Wallpaper.set(String(DesktopPreferences.get("wallpaperPath") ?? "")); root.applyWallpaper(root.currentWallpaper());
settleReload.attempts = 0; settleReload.attempts = 0;
settleReload.restart(); settleReload.restart();
@@ -101,9 +122,9 @@ Singleton {
// Let the current instances finish their external writes before a // Let the current instances finish their external writes before a
// soft reload replaces them. The cap keeps a failed external tool // soft reload replaces them. The cap keeps a failed external tool
// from leaving restored Home state stale indefinitely. // from leaving restored Home state stale indefinitely.
if ((!Keybinds.reloading && !SystemSettings.busy) || attempts >= 30) { if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
stop(); stop();
Quickshell.reload(false); root.reloadShell();
} }
} }
} }
@@ -123,19 +144,28 @@ Singleton {
} }
function serialiseHomeState(): string { function serialiseHomeState(): string {
const current = root.readHomeState();
const favorites = []; const favorites = [];
for (const favorite of HomePreferences.favorites ?? []) { for (const favorite of current.favorites ?? []) {
favorites.push({ favorites.push({
id: String(favorite.id ?? ""), id: String(favorite.id ?? ""),
alias: String(favorite.alias ?? "") alias: String(favorite.alias ?? "")
}); });
} }
return JSON.stringify({ return JSON.stringify({
initialized: HomePreferences.initialized, initialized: current.initialized === true,
favorites: favorites favorites: favorites
}); });
} }
function handleRestoreOutput(text: string): bool {
if (!root.reloadHomeState(text))
return false;
root.reloadDesktop();
applyRestoredState.restart();
return true;
}
// Restore output carries the canonical Home state. Reconstructing through // Restore output carries the canonical Home state. Reconstructing through
// these methods keeps validation and persistence inside HomePreferences; // these methods keeps validation and persistence inside HomePreferences;
// this service never mutates its aliases or private FileView directly. // this service never mutates its aliases or private FileView directly.
@@ -170,12 +200,12 @@ Singleton {
if (!data.initialized && ids.length > 0) if (!data.initialized && ids.length > 0)
return false; return false;
HomePreferences.resetHomeDefaults(); root.resetHome();
if (!data.initialized) if (!data.initialized)
return true; return true;
HomePreferences.initialize(ids); root.initializeHome(ids);
for (let index = 0; index < ids.length; index++) for (let index = 0; index < ids.length; index++)
HomePreferences.setAlias(ids[index], aliases[index]); root.aliasHome(ids[index], aliases[index]);
return true; return true;
} catch (error) { } catch (error) {
return false; return false;
@@ -183,7 +213,7 @@ Singleton {
} }
function resetHomeState(): bool { function resetHomeState(): bool {
HomePreferences.resetHomeDefaults(); root.resetHome();
return true; return true;
} }
@@ -0,0 +1,76 @@
// Isolated behavioral harness for SettingsBackup's live restore handoff.
// Every external consumer is replaced before restore output is exercised, so
// this file never writes the real compositor, wallpaper, keymap, or shell.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
ShellRoot {
id: root
property var calls: []
property bool homeInitialized: false
property var homeFavorites: []
function record(name: string): void {
const next = root.calls.slice();
next.push(name);
root.calls = next;
}
Component.onCompleted: {
SettingsBackup.readHomeState = function() {
return {
initialized: root.homeInitialized,
favorites: root.homeFavorites
};
};
SettingsBackup.resetHome = function() {
root.record("home.reset");
root.homeInitialized = false;
root.homeFavorites = [];
};
SettingsBackup.initializeHome = function(ids) {
root.record("home.initialize:" + ids.join(","));
root.homeInitialized = true;
root.homeFavorites = ids.map(id => ({ id: id, alias: "" }));
};
SettingsBackup.aliasHome = function(id, alias) {
root.record("home.alias:" + id + "=" + alias);
root.homeFavorites = root.homeFavorites.map(favorite =>
favorite.id === id ? { id: id, alias: alias } : favorite);
};
SettingsBackup.reloadDesktop = function() { root.record("desktop.reload"); };
SettingsBackup.applyCompositor = function() { root.record("system.apply"); };
SettingsBackup.reloadKeybinds = function() { root.record("keybinds.reload"); };
SettingsBackup.keybindsReloading = function() { return false; };
SettingsBackup.systemBusy = function() { return false; };
SettingsBackup.currentWallpaper = function() { return "/tmp/restored-wallpaper.jpg"; };
SettingsBackup.applyWallpaper = function(path) { root.record("wallpaper.set:" + path); };
SettingsBackup.reloadShell = function() { root.record("shell.reload"); };
}
IpcHandler {
target: "settings-backup-behavior"
function reset(): void {
root.calls = [];
root.homeInitialized = false;
root.homeFavorites = [];
}
function apply(output: string): bool {
return SettingsBackup.handleRestoreOutput(output);
}
function status(): string {
return JSON.stringify({
calls: root.calls,
initialized: root.homeInitialized,
favorites: root.homeFavorites
});
}
}
}
+70 -2
View File
@@ -24,9 +24,24 @@ trap cleanup EXIT
settings="$work/config/panama/settings.json" settings="$work/config/panama/settings.json"
home="$work/state/panama/panama-home.json" home="$work/state/panama/panama-home.json"
backups="$work/state/panama/backups" backups="$work/state/panama/backups"
transaction_dir="$work/state/panama/transactions/settings-restore"
mkdir -p "$(dirname "$settings")" mkdir -p "$(dirname "$settings")"
run() { XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" "$helper" "$@"; } 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 ─────────────────────────────────────────────────────── # ── Nothing to back up ───────────────────────────────────────────────────────
run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success' run save >/dev/null 2>&1 && fail 'backing up a missing settings file reported success'
@@ -62,6 +77,22 @@ absent_result="$(run restore "$absent_name")" || fail 'restore failed for a snap
jq -e '.home.present == false and (.home | has("data") | not)' <<<"$absent_result" >/dev/null \ 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' || fail 'restore did not return absent Home state for the live service to reload'
# Desktop absence is symmetric: a Home-only snapshot removes a desktop file
# created later and restores the Home store.
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}' >"$settings"
printf '{"initialized":false,"favorites":[]}' >"$home"
run restore "$desktop_absent_name" >/dev/null || fail 'Home-only snapshot restore failed'
[[ ! -e "$settings" ]] || fail 'restore did not preserve the snapshots absent desktop state'
[[ "$(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 # A legacy settings-only snapshot predates presence metadata. Its safest
# interpretation is to restore desktop settings without deleting current Home # interpretation is to restore desktop settings without deleting current Home
# state that the old format knew nothing about. # state that the old format knew nothing about.
@@ -72,6 +103,43 @@ 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 .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' [[ "$(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 ────────────────── # ── Restoring snapshots what it replaced, so it is undoable ──────────────────
count="$(run list | jq 'length')" count="$(run list | jq 'length')"
[[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)" [[ "$count" -ge 2 ]] || fail "restore did not snapshot the replaced settings (only $count snapshots)"
@@ -81,7 +149,7 @@ bad="settings-19990101-000000000.json"
mkdir -p "$backups" mkdir -p "$backups"
printf '{ truncated' >"$backups/$bad" printf '{ truncated' >"$backups/$bad"
run restore "$bad" >/dev/null 2>&1 && fail 'a corrupt snapshot was restored' 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' [[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'a refused restore still damaged the settings file'
invalid_home="settings-19990101-000000001.json" invalid_home="settings-19990101-000000001.json"
jq -n '{ jq -n '{
@@ -96,7 +164,7 @@ jq -n '{
}} }}
}' >"$backups/$invalid_home" }' >"$backups/$invalid_home"
run restore "$invalid_home" >/dev/null 2>&1 && fail 'a snapshot with duplicate Home favourites was restored' 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' [[ "$(jq -r .gapsOut "$settings")" == "91" ]] || fail 'an invalid Home snapshot still damaged desktop settings'
printf '{ truncated' >"$home" printf '{ truncated' >"$home"
run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up' run save >/dev/null 2>&1 && fail 'a corrupt Home state file was backed up'
+112 -27
View File
@@ -1,47 +1,132 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The helper proves the two stores round-trip in an isolated XDG tree. This # Behavioral coverage for the QML handoff after the helper commits a restore.
# source contract pins the live handoff without launching a second copy of the # The harness has a unique shell identity, isolated XDG roots, and fake external
# daily-driver shell or invoking Hyprland during tests. # consumers. It records the real SettingsBackup call order without touching the
# daily-driver shell, compositor, keymap, or wallpaper.
set -euo pipefail set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml" service="$repo_dir/config/dot/quickshell/services/SettingsBackup.qml"
harness="$repo_dir/config/dot/quickshell/settings-backup-harness.qml"
work="$(mktemp -d /tmp/panama-settings-backup-live.XXXXXX)"
fail() { fail() {
printf 'settings backup live contract: %s\n' "$1" >&2 printf 'settings backup live contract: %s\n' "$1" >&2
exit 1 exit 1
} }
rg -q 'DesktopPreferences\.reload\(\)' "$service" \ qs_test() {
|| fail 'restore does not reload DesktopPreferences' XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
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 cleanup() {
# shell command would make validation in the helper the only line of defence. qs_test kill >/dev/null 2>&1 || true
rg -q 'actionRun\.exec\(\[root\.helperPath, "restore", name\]\)' "$service" \ rm -rf "$work"
}
trap cleanup EXIT
# The production command boundary must remain argv-only.
rg -Fq 'actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);' "$service" \
|| fail 'save does not pass live Home state as one argument'
rg -Fq 'actionRun.exec([root.helperPath, "restore", name]);' "$service" \
|| fail 'restore is not executed through an argument array' || fail 'restore is not executed through an argument array'
if rg -q 'bash.*-c|sh.*-c' "$service"; then if rg -q 'bash.*-c|sh.*-c' "$service"; then
fail 'the restore service constructs a shell command' fail 'the restore service constructs a shell command'
fi fi
# The harness replaces these seams, while these mappings prove the production
# defaults still delegate to Panama's existing public service APIs.
for mapping in \
'HomePreferences.resetHomeDefaults();' \
'HomePreferences.initialize(ids);' \
'HomePreferences.setAlias(id, alias);' \
'DesktopPreferences.reload();' \
'SystemSettings.applyPersistedDisplayPolicy();' \
'Keybinds.applyReload();' \
'Wallpaper.set(path);' \
'Quickshell.reload(false);'; do
rg -Fq "$mapping" "$service" || fail "production restore seam is missing: $mapping"
done
qs_test --daemonize >"$work/quickshell.log" 2>&1
ready=false
for _ in $(seq 1 60); do
if qs_test ipc show 2>/dev/null | rg -q '^target settings-backup-behavior$'; then
ready=true
break
fi
sleep 0.1
done
if [[ "$ready" != true ]]; then
sed -n '1,200p' "$work/quickshell.log" >&2
fail 'isolated SettingsBackup harness did not start'
fi
qs_test ipc call settings-backup-behavior reset >/dev/null
payload='{"restored":"settings-20260818-010203004.json","home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"Desk"},{"id":"light.office","alias":"Office"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$payload")" == "true" ]] \
|| fail 'valid restore output was rejected'
status=""
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"home.initialize:light.desk,light.office",
"home.alias:light.desk=Desk",
"home.alias:light.office=Office",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == true
and .favorites == [
{"id":"light.desk","alias":"Desk"},
{"id":"light.office","alias":"Office"}
]
' <<<"$status" >/dev/null || fail "restore handoff order/state was wrong: $status"
# Invalid output is rejected before Home state or external consumers change.
qs_test ipc call settings-backup-behavior reset >/dev/null
invalid='{"home":{"present":true,"data":{"initialized":true,"favorites":[{"id":"light.desk","alias":"One"},{"id":"light.desk","alias":"Two"}]}}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$invalid")" == "false" ]] \
|| fail 'duplicate Home state was accepted'
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and .initialized == false and .favorites == []' <<<"$status" >/dev/null \
|| fail 'invalid restore output caused partial live mutations'
# An absent Home generation uses the same ordered external handoff but leaves
# the live Home service reset rather than manufacturing an initialized store.
qs_test ipc call settings-backup-behavior reset >/dev/null
absent='{"restored":"settings-20260818-010203005.json","home":{"present":false}}'
[[ "$(qs_test ipc call settings-backup-behavior apply "$absent")" == "true" ]] \
|| fail 'absent Home restore output was rejected'
for _ in $(seq 1 50); do
status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls[-1] == "shell.reload"' <<<"$status" >/dev/null 2>&1 && break
sleep 0.1
done
jq -e '
.calls == [
"home.reset",
"desktop.reload",
"system.apply",
"keybinds.reload",
"wallpaper.set:/tmp/restored-wallpaper.jpg",
"shell.reload"
]
and .initialized == false
and .favorites == []
' <<<"$status" >/dev/null || fail "absent Home handoff was wrong: $status"
trap - EXIT
cleanup
printf 'settings backup live contract: PASS\n' printf 'settings backup live contract: PASS\n'