Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83391e0453 | ||
|
|
172b099a04 |
@@ -1,90 +1,625 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Snapshots of the Panama settings store.
|
||||
#
|
||||
# The whole desktop configuration is one JSON file, which makes a backup a copy
|
||||
# and a restore an overwrite. That is worth exposing: the settings app now
|
||||
# changes real things -- compositor geometry, idle timeouts, the dock -- and
|
||||
# being able to get back to a known-good state without hunting through git is
|
||||
# the difference between experimenting freely and being cautious.
|
||||
#
|
||||
# panama-settings-backup save snapshot the current settings
|
||||
# panama-settings-backup list JSON list of snapshots, newest first
|
||||
# panama-settings-backup restore <name> replace settings with a snapshot
|
||||
#
|
||||
# Snapshots are validated as JSON on the way in and on the way out, so a
|
||||
# truncated file can never be restored over a working configuration.
|
||||
#
|
||||
# Names carry milliseconds. At one-second resolution a save followed promptly by
|
||||
# a restore produced the same filename twice, and the restore's own safety
|
||||
# snapshot overwrote the very file it was about to read.
|
||||
"""Crash-safe snapshots of Panama's desktop and Home preference stores.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
settings="${XDG_CONFIG_HOME:-$HOME/.config}/panama/settings.json"
|
||||
backup_dir="${XDG_STATE_HOME:-$HOME/.local/state}/panama/backups"
|
||||
keep=15
|
||||
from __future__ import annotations
|
||||
|
||||
fail() {
|
||||
printf '%s\n' "$1" >&2
|
||||
exit 1
|
||||
import json
|
||||
import os
|
||||
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
|
||||
|
||||
|
||||
HOME = Path(os.environ.get("HOME", str(Path.home())))
|
||||
CONFIG_ROOT = Path(os.environ.get("XDG_CONFIG_HOME", str(HOME / ".config")))
|
||||
STATE_ROOT = Path(os.environ.get("XDG_STATE_HOME", str(HOME / ".local/state")))
|
||||
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_]+$")
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def fail(message: str) -> NoReturn:
|
||||
raise BackupError(message)
|
||||
|
||||
|
||||
def fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def ensure_directory(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if path.is_symlink() or not path.is_dir():
|
||||
fail(f"{path} is not a safe directory.")
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def is_present(path: Path) -> bool:
|
||||
return path.exists() or path.is_symlink()
|
||||
|
||||
|
||||
def require_regular(path: Path, label: str) -> None:
|
||||
if path.is_symlink():
|
||||
fail(f"{label} is a symbolic link and cannot be used safely.")
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
except FileNotFoundError:
|
||||
fail(f"{label} is missing.")
|
||||
if not stat.S_ISREG(mode):
|
||||
fail(f"{label} is not a regular file.")
|
||||
|
||||
|
||||
def read_json(path: Path, label: str) -> dict[str, Any]:
|
||||
require_regular(path, label)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
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
|
||||
|
||||
|
||||
def valid_home(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
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
|
||||
|
||||
|
||||
def validate_home(value: Any, label: str) -> dict[str, Any]:
|
||||
if not valid_home(value):
|
||||
fail(f"{label} does not contain valid Home favourites.")
|
||||
return value
|
||||
|
||||
|
||||
def json_bytes(value: Any) -> bytes:
|
||||
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
|
||||
def atomic_write_bytes(path: Path, content: bytes) -> None:
|
||||
ensure_directory(path.parent)
|
||||
if path.is_symlink():
|
||||
fail(f"{path} is a symbolic link and cannot be replaced safely.")
|
||||
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()
|
||||
|
||||
|
||||
def atomic_write_json(path: Path, value: Any) -> None:
|
||||
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
|
||||
if TRANSACTION_DIR.is_symlink() or not TRANSACTION_DIR.is_dir():
|
||||
fail("The restore transaction path is not a safe directory.")
|
||||
for child in list(TRANSACTION_DIR.iterdir()):
|
||||
if child.name not in {
|
||||
"journal.json",
|
||||
"desktop.old",
|
||||
"desktop.new",
|
||||
"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)
|
||||
|
||||
|
||||
def clean_stale_atomic_files() -> None:
|
||||
locations = (
|
||||
(SETTINGS.parent, (".settings.json.",)),
|
||||
(HOME_STATE.parent, (".panama-home.json.",)),
|
||||
(BACKUP_DIR, (".settings-",)),
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def validate_journal_side(value: Any) -> dict[str, bool]:
|
||||
if not isinstance(value, dict):
|
||||
fail("The restore journal is malformed.")
|
||||
if set(value) != {"touch", "oldPresent", "newPresent"}:
|
||||
fail("The restore journal is malformed.")
|
||||
if not all(isinstance(value[key], bool) for key in value):
|
||||
fail("The restore journal is malformed.")
|
||||
return value
|
||||
|
||||
|
||||
def read_journal() -> dict[str, Any]:
|
||||
value = read_json(JOURNAL, "The restore journal")
|
||||
if set(value) != {"version", "desktop", "home"} or value.get("version") != 1:
|
||||
fail("The restore journal uses an unsupported format.")
|
||||
return {
|
||||
"version": 1,
|
||||
"desktop": validate_journal_side(value.get("desktop")),
|
||||
"home": validate_journal_side(value.get("home")),
|
||||
}
|
||||
|
||||
case "${1:-list}" in
|
||||
save)
|
||||
[[ -r "$settings" ]] || fail "No settings file to back up."
|
||||
jq -e . "$settings" >/dev/null 2>&1 || fail "The current settings file is not valid JSON."
|
||||
mkdir -p "$backup_dir"
|
||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
||||
cp "$settings" "$backup_dir/settings-$stamp.json"
|
||||
# Keep the most recent few. A snapshot per change would otherwise grow
|
||||
# without bound in a directory nobody ever looks at.
|
||||
ls -1t "$backup_dir"/settings-*.json 2>/dev/null | tail -n +$((keep + 1)) | while read -r old; do
|
||||
rm -f "$old"
|
||||
done
|
||||
printf '{"saved":"settings-%s.json"}\n' "$stamp"
|
||||
;;
|
||||
|
||||
list)
|
||||
mkdir -p "$backup_dir"
|
||||
first=true
|
||||
printf '['
|
||||
for file in $(ls -1t "$backup_dir"/settings-*.json 2>/dev/null); do
|
||||
name="$(basename "$file")"
|
||||
# settings-20260818-004512.json -> 2026-08-18 00:45
|
||||
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}"
|
||||
keys="$(jq -r 'keys | length' "$file" 2>/dev/null || printf 0)"
|
||||
[[ "$first" == true ]] || printf ','
|
||||
first=false
|
||||
printf '{"name":"%s","when":"%s","keys":%s}' "$name" "$pretty" "$keys"
|
||||
done
|
||||
printf ']\n'
|
||||
;;
|
||||
def target_for(store: str) -> Path:
|
||||
if store == "desktop":
|
||||
return SETTINGS
|
||||
if store == "home":
|
||||
return HOME_STATE
|
||||
fail("The restore journal names an unknown store.")
|
||||
|
||||
restore)
|
||||
name="${2:-}"
|
||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||
# Only a bare filename from the backup directory, so a caller cannot
|
||||
# walk out of it with a path.
|
||||
[[ "$name" =~ ^settings-[0-9]{8}-[0-9]{9}\.json$ ]] || fail "Not a snapshot name."
|
||||
source_file="$backup_dir/$name"
|
||||
[[ -r "$source_file" ]] || fail "That snapshot is missing."
|
||||
jq -e . "$source_file" >/dev/null 2>&1 || fail "That snapshot is not valid JSON."
|
||||
|
||||
# Snapshot what is being replaced, so restore is itself undoable.
|
||||
if [[ -r "$settings" ]] && jq -e . "$settings" >/dev/null 2>&1; then
|
||||
mkdir -p "$backup_dir"
|
||||
cp "$settings" "$backup_dir/settings-$(date +%Y%m%d-%H%M%S%3N).json"
|
||||
fi
|
||||
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)
|
||||
|
||||
mkdir -p "$(dirname "$settings")"
|
||||
cp "$source_file" "$settings.tmp"
|
||||
mv "$settings.tmp" "$settings"
|
||||
printf '{"restored":"%s"}\n' "$name"
|
||||
;;
|
||||
|
||||
*)
|
||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
||||
;;
|
||||
esac
|
||||
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},
|
||||
}
|
||||
if desktop_present:
|
||||
envelope["desktop"]["data"] = desktop
|
||||
if home_present:
|
||||
envelope["home"]["data"] = home
|
||||
|
||||
destination = next_snapshot_path()
|
||||
atomic_write_json(destination, envelope)
|
||||
prune_snapshots()
|
||||
return destination
|
||||
|
||||
|
||||
def snapshot_source(name: str) -> Path:
|
||||
if SNAPSHOT_RE.fullmatch(name) is None:
|
||||
fail("Not a snapshot name.")
|
||||
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
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
pragma Singleton
|
||||
|
||||
// Snapshots of the settings store.
|
||||
// Snapshots of Panama's durable settings stores.
|
||||
//
|
||||
// The whole desktop configuration is one JSON file, so a backup is a copy and a
|
||||
// restore is an overwrite. Worth exposing now that the settings app changes
|
||||
// real things -- compositor geometry, idle timeouts, the dock -- because being
|
||||
// able to return to a known-good state is what makes experimenting feel safe.
|
||||
// DesktopPreferences and HomePreferences use separate files. The helper owns
|
||||
// the transactional filesystem boundary; this service owns settling the live
|
||||
// desktop after those files have changed underneath it.
|
||||
//
|
||||
// Restoring rewrites the file underneath the running shell, so the store is
|
||||
// told to re-read afterwards rather than waiting for the next change.
|
||||
// HomePreferences intentionally keeps its FileView in Quickshell's private
|
||||
// state directory while snapshots use Panama's canonical state directory. This
|
||||
// service bridges them through HomePreferences' public mutation API, then soft
|
||||
// reloads once external consumers have settled.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -24,7 +25,31 @@ Singleton {
|
||||
property string lastError: ""
|
||||
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
|
||||
|| applyRestoredState.running || settleReload.running
|
||||
|
||||
Process {
|
||||
id: listQuery
|
||||
@@ -34,6 +59,7 @@ Singleton {
|
||||
try {
|
||||
const parsed = JSON.parse(this.text);
|
||||
root.snapshots = Array.isArray(parsed) ? parsed : [];
|
||||
if (root.lastError === "Could not read the list of snapshots.")
|
||||
root.lastError = "";
|
||||
} catch (error) {
|
||||
root.lastError = "Could not read the list of snapshots.";
|
||||
@@ -45,6 +71,11 @@ Singleton {
|
||||
Process {
|
||||
id: actionRun
|
||||
property bool restoring: false
|
||||
property string outputText: ""
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: actionRun.outputText = this.text
|
||||
}
|
||||
onStarted: actionRun.outputText = ""
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.lastError = actionRun.restoring
|
||||
@@ -52,14 +83,52 @@ Singleton {
|
||||
: "The settings could not be backed up.";
|
||||
return;
|
||||
}
|
||||
root.lastError = "";
|
||||
root.lastAction = actionRun.restoring ? "restored" : "saved";
|
||||
if (actionRun.restoring)
|
||||
DesktopPreferences.reload();
|
||||
if (actionRun.restoring) {
|
||||
const homeReloaded = root.handleRestoreOutput(actionRun.outputText);
|
||||
root.lastError = homeReloaded
|
||||
? ""
|
||||
: "Desktop settings were restored, but Home favourites could not be reloaded.";
|
||||
} else
|
||||
root.lastError = "";
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: applyRestoredState
|
||||
interval: 80
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
// DesktopPreferences.reload() invalidates reactive shell bindings.
|
||||
// These services also own state outside QML and need an explicit
|
||||
// replay: compositor options, Lua-generated binds, and hyprpaper.
|
||||
root.applyCompositor();
|
||||
root.reloadKeybinds();
|
||||
root.applyWallpaper(root.currentWallpaper());
|
||||
|
||||
settleReload.attempts = 0;
|
||||
settleReload.restart();
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: settleReload
|
||||
property int attempts: 0
|
||||
interval: 100
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
attempts++;
|
||||
// Let the current instances finish their external writes before a
|
||||
// soft reload replaces them. The cap keeps a failed external tool
|
||||
// from leaving restored Home state stale indefinitely.
|
||||
if ((!root.keybindsReloading() && !root.systemBusy()) || attempts >= 30) {
|
||||
stop();
|
||||
root.reloadShell();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: root.refresh()
|
||||
|
||||
function refresh(): void {
|
||||
@@ -71,7 +140,81 @@ Singleton {
|
||||
if (actionRun.running)
|
||||
return;
|
||||
actionRun.restoring = false;
|
||||
actionRun.exec([root.helperPath, "save"]);
|
||||
actionRun.exec([root.helperPath, "save", root.serialiseHomeState()]);
|
||||
}
|
||||
|
||||
function serialiseHomeState(): string {
|
||||
const current = root.readHomeState();
|
||||
const favorites = [];
|
||||
for (const favorite of current.favorites ?? []) {
|
||||
favorites.push({
|
||||
id: String(favorite.id ?? ""),
|
||||
alias: String(favorite.alias ?? "")
|
||||
});
|
||||
}
|
||||
return JSON.stringify({
|
||||
initialized: current.initialized === true,
|
||||
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
|
||||
// these methods keeps validation and persistence inside HomePreferences;
|
||||
// this service never mutates its aliases or private FileView directly.
|
||||
function reloadHomeState(text: string): bool {
|
||||
try {
|
||||
const result = JSON.parse(text);
|
||||
const restored = result?.home;
|
||||
if (!restored || restored.preserve === true)
|
||||
return true;
|
||||
|
||||
if (restored.present !== true)
|
||||
return restored.present === false
|
||||
? root.resetHomeState()
|
||||
: false;
|
||||
|
||||
const data = restored.data;
|
||||
if (!data || typeof data.initialized !== "boolean" || !Array.isArray(data.favorites))
|
||||
return false;
|
||||
const ids = [];
|
||||
const aliases = [];
|
||||
const seen = {};
|
||||
for (const favorite of data.favorites) {
|
||||
const id = favorite?.id;
|
||||
const alias = favorite?.alias;
|
||||
if (typeof id !== "string" || !/^light\.[a-z0-9_]+$/.test(id)
|
||||
|| typeof alias !== "string" || seen[id])
|
||||
return false;
|
||||
seen[id] = true;
|
||||
ids.push(id);
|
||||
aliases.push(alias);
|
||||
}
|
||||
if (!data.initialized && ids.length > 0)
|
||||
return false;
|
||||
|
||||
root.resetHome();
|
||||
if (!data.initialized)
|
||||
return true;
|
||||
root.initializeHome(ids);
|
||||
for (let index = 0; index < ids.length; index++)
|
||||
root.aliasHome(ids[index], aliases[index]);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetHomeState(): bool {
|
||||
root.resetHome();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The name is matched against the snapshot list rather than trusted, so no
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,10 +22,26 @@ 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'
|
||||
@@ -33,15 +49,96 @@ 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 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: 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 snapshot’s 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
|
||||
# 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')"
|
||||
@@ -52,12 +149,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")" == "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 favourites 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 ─────────────────────────────────
|
||||
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Behavioral coverage for the QML handoff after the helper commits a restore.
|
||||
# The harness has a unique shell identity, isolated XDG roots, and fake external
|
||||
# consumers. It records the real SettingsBackup call order without touching the
|
||||
# daily-driver shell, compositor, keymap, or wallpaper.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
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() {
|
||||
printf 'settings backup live contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
qs_test() {
|
||||
XDG_CONFIG_HOME="$work/config" XDG_STATE_HOME="$work/state" qs -p "$harness" "$@"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
qs_test kill >/dev/null 2>&1 || true
|
||||
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'
|
||||
if rg -q 'bash.*-c|sh.*-c' "$service"; then
|
||||
fail 'the restore service constructs a shell command'
|
||||
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'
|
||||
Reference in New Issue
Block a user