Make settings restore crash-safe
This commit is contained in:
@@ -1,391 +1,625 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/usr/bin/env python3
|
||||
|
||||
# Versioned snapshots of Panama's durable settings 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.
|
||||
"""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.
|
||||
"""
|
||||
|
||||
config_root="${XDG_CONFIG_HOME:-$HOME/.config}"
|
||||
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))'
|
||||
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
|
||||
|
||||
store_present() {
|
||||
[[ -e "$1" || -L "$1" ]]
|
||||
}
|
||||
|
||||
store_valid() {
|
||||
local path="$1"
|
||||
[[ -f "$path" && ! -L "$path" && -r "$path" ]] \
|
||||
&& jq -e 'type == "object"' "$path" >/dev/null 2>&1
|
||||
}
|
||||
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_]+$")
|
||||
|
||||
home_store_valid() {
|
||||
local path="$1"
|
||||
store_valid "$path" && jq -e "$home_json_filter" "$path" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
validate_store() {
|
||||
local path="$1"
|
||||
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."
|
||||
}
|
||||
class BackupError(RuntimeError):
|
||||
pass
|
||||
|
||||
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
|
||||
# directory. SettingsBackup passes its public values as one argv element so the
|
||||
# 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
|
||||
def fail(message: str) -> NoReturn:
|
||||
raise BackupError(message)
|
||||
|
||||
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() {
|
||||
local stamp
|
||||
local candidate
|
||||
while true; do
|
||||
stamp="$(date +%Y%m%d-%H%M%S%3N)"
|
||||
candidate="$backup_dir/settings-$stamp.json"
|
||||
if [[ ! -e "$candidate" && ! -L "$candidate" ]]; then
|
||||
snapshot_stamp="$stamp"
|
||||
snapshot_path="$candidate"
|
||||
return
|
||||
fi
|
||||
sleep 0.002
|
||||
done
|
||||
}
|
||||
def fsync_directory(path: Path) -> None:
|
||||
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
prune_snapshots() {
|
||||
local -a entries=()
|
||||
local index
|
||||
local path
|
||||
mapfile -d '' entries < <(
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
for ((index = keep; index < ${#entries[@]}; index++)); do
|
||||
path="${entries[$index]#* }"
|
||||
rm -f -- "$path"
|
||||
done
|
||||
}
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
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
|
||||
store_present "$home" && home_present=true
|
||||
[[ "$desktop_present" == true || "$home_present" == true ]] \
|
||||
|| return 1
|
||||
def is_present(path: Path) -> bool:
|
||||
return path.exists() or path.is_symlink()
|
||||
|
||||
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
|
||||
jq -n --slurpfile desktop "$settings" --slurpfile home "$home" '{
|
||||
version: 2,
|
||||
desktop: { present: true, data: $desktop[0] },
|
||||
home: { present: true, data: $home[0] }
|
||||
}' >"$temp"
|
||||
elif [[ "$desktop_present" == true ]]; then
|
||||
jq -n --slurpfile desktop "$settings" '{
|
||||
version: 2,
|
||||
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
|
||||
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.")
|
||||
|
||||
mv -- "$temp" "$snapshot_path"
|
||||
prune_snapshots
|
||||
}
|
||||
|
||||
snapshot_source() {
|
||||
local name="$1"
|
||||
local candidate="$backup_dir/$name"
|
||||
local canonical_dir
|
||||
local canonical_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
|
||||
|
||||
[[ "$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")"
|
||||
canonical_file="$(realpath -e -- "$candidate")"
|
||||
[[ "$canonical_file" == "$canonical_dir/"* ]] \
|
||||
|| fail "That snapshot is outside the backup directory."
|
||||
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
|
||||
|
||||
printf '%s\n' "$candidate"
|
||||
}
|
||||
|
||||
validate_snapshot() {
|
||||
local source_file="$1"
|
||||
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
|
||||
|
||||
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
|
||||
jq -e '
|
||||
.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
|
||||
}
|
||||
def json_bytes(value: Any) -> bytes:
|
||||
return (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
|
||||
stage_json() {
|
||||
local source_file="$1"
|
||||
local filter="$2"
|
||||
local directory="$3"
|
||||
local template="$4"
|
||||
local staged
|
||||
|
||||
mkdir -p "$directory"
|
||||
staged="$(mktemp "$directory/$template.XXXXXX")"
|
||||
chmod 600 "$staged"
|
||||
jq -e "$filter" "$source_file" >"$staged"
|
||||
printf '%s\n' "$staged"
|
||||
}
|
||||
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()
|
||||
|
||||
backup_current_file() {
|
||||
local target="$1"
|
||||
local directory="$2"
|
||||
local template="$3"
|
||||
|
||||
if ! store_present "$target"; then
|
||||
printf '\n'
|
||||
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
|
||||
fi
|
||||
[[ -f "$target" && ! -L "$target" ]] \
|
||||
|| fail "A settings target is not a regular file."
|
||||
local rollback
|
||||
rollback="$(mktemp "$directory/$template.XXXXXX")"
|
||||
chmod 600 "$rollback"
|
||||
cp -- "$target" "$rollback"
|
||||
printf '%s\n' "$rollback"
|
||||
}
|
||||
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)
|
||||
|
||||
restore_target() {
|
||||
local target="$1"
|
||||
local present="$2"
|
||||
local staged="$3"
|
||||
|
||||
if [[ "$present" == true ]]; then
|
||||
mv -- "$staged" "$target"
|
||||
else
|
||||
rm -f -- "$target"
|
||||
fi
|
||||
}
|
||||
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)
|
||||
|
||||
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"
|
||||
;;
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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")),
|
||||
}
|
||||
|
||||
|
||||
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.")
|
||||
|
||||
|
||||
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"
|
||||
)
|
||||
printf ']\n'
|
||||
;;
|
||||
home_present, home = current_store(
|
||||
HOME_STATE, "The current Home state file", home_store=True
|
||||
)
|
||||
except BackupError:
|
||||
if validate:
|
||||
raise
|
||||
return None
|
||||
|
||||
restore)
|
||||
name="${2:-}"
|
||||
[[ -n "$name" ]] || fail "Which snapshot?"
|
||||
source_file="$(snapshot_source "$name")"
|
||||
validate_snapshot "$source_file"
|
||||
if not desktop_present and not home_present:
|
||||
if require_any:
|
||||
fail("No Panama settings exist to back up.")
|
||||
return None
|
||||
|
||||
desktop_present=true
|
||||
home_action="preserve"
|
||||
if [[ "$snapshot_format" == "versioned" ]]; then
|
||||
desktop_present="$(jq -r '.desktop.present' "$source_file")"
|
||||
home_action="$(jq -r 'if .home.present then "present" else "absent" end' "$source_file")"
|
||||
if [[ "$desktop_present" == true ]]; then
|
||||
desktop_stage="$(stage_json "$source_file" '.desktop.data' "$(dirname "$settings")" '.settings-restore')"
|
||||
else
|
||||
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
|
||||
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
|
||||
|
||||
# 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
|
||||
destination = next_snapshot_path()
|
||||
atomic_write_json(destination, envelope)
|
||||
prune_snapshots()
|
||||
return destination
|
||||
|
||||
settings_dir="$(dirname "$settings")"
|
||||
home_dir="$(dirname "$home")"
|
||||
settings_rollback="$(backup_current_file "$settings" "$settings_dir" '.settings-rollback')"
|
||||
home_rollback="$(backup_current_file "$home" "$home_dir" '.home-rollback')"
|
||||
rollback_needed=true
|
||||
|
||||
rollback() {
|
||||
if [[ "$settings_rollback" != "" ]]; then
|
||||
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
|
||||
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
|
||||
|
||||
cleanup_restore() {
|
||||
local status=$?
|
||||
if [[ "$rollback_needed" == true ]]; then
|
||||
rollback || true
|
||||
fi
|
||||
[[ "${desktop_stage:-}" == "" ]] || rm -f -- "$desktop_stage"
|
||||
[[ "${home_stage:-}" == "" ]] || rm -f -- "$home_stage"
|
||||
[[ "$settings_rollback" == "" ]] || rm -f -- "$settings_rollback"
|
||||
[[ "$home_rollback" == "" ]] || rm -f -- "$home_rollback"
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup_restore EXIT
|
||||
|
||||
restore_target "$settings" "$desktop_present" "$desktop_stage"
|
||||
if [[ "$home_action" != "preserve" ]]; then
|
||||
restore_target "$home" "$([[ "$home_action" == "present" ]] && printf true || printf false)" "$home_stage"
|
||||
fi
|
||||
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)
|
||||
|
||||
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
|
||||
;;
|
||||
home = journal["home"]
|
||||
if home["touch"]:
|
||||
apply_artifact("home", "new", home["newPresent"])
|
||||
|
||||
*)
|
||||
fail "usage: panama-settings-backup [save|list|restore <name>]"
|
||||
;;
|
||||
esac
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user