Add a snapshot manager, and start covering home

The machine already had snapper running hourly on btrfs, so the tool was
never missing. What was missing is that snapper's only configuration
covered / -- and /home is a separate subvolume with no configuration at
all. Six hundred and forty-three snapshots existed and not one of them
contained a document. Anyone reaching for file history would have found
their system and none of their files.

/home now has a configuration on the same hourly timeline, with
deliberately conservative retention: Steam's 1.2 TB lives on that
subvolume and churns on every game update, so keeping five hourly and
seven daily bounds what those updates can pin.

Per volume, because on this machine "one is covered and the important
one is not" was the news, and a timeline opening on system snapshots
would have buried it. Inside a volume the timeline is the familiar view:
points in time, newest first, each openable as a folder tree to take a
file out of.

Restoring sets the current version aside as .before-restore-N rather
than overwriting it. A restore that destroys the thing you were about to
compare against is how someone loses the work they were trying to save.

Rollback is deliberately absent. snapper's rollback changes the btrfs
default subvolume, and this system's fstab pins subvol= explicitly,
which overrides it -- so a rollback would report success and change
nothing after a reboot. A recovery feature that silently does nothing is
worse than not having one, and making it work means editing fstab and
the bootloader, whose failure cannot be repaired from inside the
desktop.

Per-snapshot size is reported as not measured, because measuring it
needs btrfs quotas that cost performance on every write. Free space is
shown instead, which is the number that decides anything.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-19 16:11:50 -04:00
parent e9d567aa72
commit 1f40f8e136
11 changed files with 1093 additions and 1 deletions
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Getting a file back must never lose the file that was already there, and
# nothing here may touch how the machine boots.
#
# Four rules:
#
# 1. Restore sets the current version aside instead of overwriting it. A
# restore that destroys what you were about to compare against is how
# someone loses the work they were trying to save.
# 2. No rollback. snapper's rollback changes the btrfs default subvolume, and
# this system's fstab pins subvol= explicitly, which overrides it -- so a
# rollback would report success and change nothing after a reboot. A
# recovery feature that silently does nothing is worse than none.
# 3. Paths cannot escape the snapshot they came from.
# 4. Snapshot 0 is the live filesystem, not a snapshot, and can never be a
# target for reading or deleting.
#
# Read-only: it reads snapshot state and exercises refusals. It never creates,
# deletes, or restores anything.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-snapshots"
service="$repo_dir/config/dot/quickshell/services/Snapshots.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/SnapshotsPage.qml"
fail() {
printf 'snapshots contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$page"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-snapshots is not executable'
# ── 1. Restore keeps what was there ─────────────────────────────────────────
restore_body="$(sed -n '/^def restore/,/^def /p' "$helper")"
[[ -n "$restore_body" ]] || fail 'restore is missing'
grep -q 'before-restore' <<<"$restore_body" \
|| fail 'restore does not set the current version aside'
grep -q 'os.rename(destination, kept)' <<<"$restore_body" \
|| fail 'the current version is not moved before the snapshot copy is written'
# The move must happen BEFORE the copy, or there is nothing left to move.
rename_line="$(grep -n 'os.rename(destination, kept)' <<<"$restore_body" | head -1 | cut -d: -f1)"
copy_line="$(grep -n 'shutil.copy' <<<"$restore_body" | head -1 | cut -d: -f1)"
[[ -n "$rename_line" && -n "$copy_line" && "$rename_line" -lt "$copy_line" ]] \
|| fail 'the snapshot copy is written before the current version is set aside'
# ── 2. No rollback ──────────────────────────────────────────────────────────
grep -qE '"rollback"|set-default|btrfs subvolume set-default|undochange' "$helper" \
&& fail 'the helper reaches for rollback, which this system fstab would silently ignore'
grep -qiE 'rollback' "$(dirname "$page")/$(basename "$page")" \
| grep -v '^\s*//' >/dev/null 2>&1
page_code="$(grep -vE '^\s*//' "$page")"
grep -qi 'rollback' <<<"$page_code" \
&& fail 'the page offers rollback'
# ── 3. Paths cannot escape ──────────────────────────────────────────────────
grep -q 'def safe_relative' "$helper" || fail 'there is no path containment check'
command -v jq >/dev/null 2>&1 || { printf 'snapshots contract: SKIP (no jq)\n'; exit 0; }
snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed'
config="$(jq -r '.configs[0].name // ""' <<<"$snapshot")"
number="$(jq -r '.configs[0].snapshots[0].number // 0' <<<"$snapshot")"
if [[ -n "$config" && "$number" != "0" ]]; then
refusal() { "$helper" "$@" 2>/dev/null | jq -r '.error // ""'; }
for bad in "../../etc" "../.." "gib/../../../etc"; do
answer="$(refusal browse "$config" "$number" "$bad")"
[[ "$answer" == "That path is not inside the snapshot." ]] \
|| fail "browsing \"$bad\" was not refused by the containment check: $answer"
done
answer="$(refusal restore "$config" "$number" "../../etc/passwd")"
[[ "$answer" == "That path is not inside the snapshot." ]] \
|| fail "restoring \"../../etc/passwd\" was not refused: $answer"
# ── 4. The live filesystem is not a snapshot ────────────────────────────
# The REASON again: with the guard removed, snapshot 0 fails anyway because
# its directory does not exist -- so a test that accepts any error passes
# with the guard deleted and proves nothing.
for answer in "$(refusal browse "$config" 0 "")" "$(refusal delete "$config" 0)"; do
[[ "$answer" == "That is the current state, not a snapshot." ]] \
|| fail "snapshot 0 was rejected for the wrong reason, so the live filesystem is not actually guarded: $answer"
done
fi
# ── Shape ───────────────────────────────────────────────────────────────────
jq -e '(.configs | type == "array") and (.unprotected | type == "array") and (.space | type == "object")' \
<<<"$snapshot" >/dev/null || fail 'the snapshot is missing configs, unprotected, or space'
jq -e '[.configs[] | has("name") and has("subvolume") and has("timelineEnabled") and has("limits")] | all' \
<<<"$snapshot" >/dev/null || fail 'a configuration is missing its name, subvolume, timeline flag, or limits'
jq -e '[.configs[].snapshots[]? | .number > 0] | all' <<<"$snapshot" >/dev/null \
|| fail 'the live filesystem is listed as a snapshot'
# ── Destructive actions are confirmed ───────────────────────────────────────
grep -q 'confirmingDelete' "$page" || fail 'the page deletes a snapshot without confirming'
grep -q 'confirmingRestore' "$page" || fail 'the page restores without confirming'
grep -q 'keeping whatever is there now' "$page" \
|| fail 'the page does not say that restoring keeps the current version'
# ── Space is reported honestly ──────────────────────────────────────────────
# Per-snapshot size needs btrfs quotas, which cost performance on every write.
# A made-up number would be worse than saying it is not measured.
grep -q 'Not measured' "$page" \
|| fail 'the page reports a per-snapshot size it cannot actually measure'
printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback)\n' \
"$(jq '.configs | length' <<<"$snapshot")" \
"$(jq '[.configs[].snapshots[]?] | length' <<<"$snapshot")"