#!/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")"