#!/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' # ── 5. The browser opens from a card that is closed ───────────────────────── # # The bug this pins: the file browser used to be drawn INSIDE the volume card's # expanded body, so "Browse this snapshot" from a collapsed card set the # browsing state and rendered nothing. The user pressed a button and the page # did not move. # # Nesting is the whole failure, so nesting is what is checked: the browser has # to be a card of its own, not a delegate inside the Repeater that draws one # card per volume, and its visibility may not mention the volume card's open # state. python3 - "$page" <<'PY' || fail 'the snapshot browser cannot open from a collapsed volume card' import re import sys source = open(sys.argv[1], encoding="utf-8").read() # Comment lines go first, so a `//` aside about the browser cannot be mistaken # for the browser. text = "\n".join("" if line.strip().startswith("//") else line for line in source.splitlines()) target = text.find("Snapshots.browseEntries") if target < 0: print("the page never lists the entries of the snapshot it is browsing", file=sys.stderr) raise SystemExit(1) # The chain of QML types enclosing one position. Braces also appear inside # strings ("\u{F0413}") and comments, so the scan has to know the difference or # the nesting it reports is fiction. def enclosing(text: str, position: int) -> list[str]: ancestors: list[str] = [] index = 0 length = len(text) while index < length: if index >= position: return [name for name in ancestors if name] char = text[index] if char == "/" and text.startswith("//", index): index = text.find("\n", index) if index < 0: break continue if char == "/" and text.startswith("/*", index): end = text.find("*/", index + 2) if end < 0: break index = end + 2 continue if char in "\"'`": index += 1 while index < length: if text[index] == "\\": index += 2 continue if text[index] == char: index += 1 break index += 1 continue if char == "{": head = text[max(0, index - 80):index].rstrip() match = re.search(r"([A-Z][A-Za-z0-9_.]*)\s*$", head) ancestors.append(match.group(1) if match else "") elif char == "}" and ancestors: ancestors.pop() index += 1 return [name for name in ancestors if name] ancestors = enclosing(text, target) if "SettingsCard" not in ancestors: print(f"the browser is not inside a card at all ({ancestors})", file=sys.stderr) raise SystemExit(1) # The listing is a Repeater of its own, which is fine. What matters is what # encloses the CARD: a Repeater above it is the per-volume one, and that is the # bug -- the browser only exists while that volume's card is drawn expanded. outside = ancestors[:len(ancestors) - 1 - ancestors[::-1].index("SettingsCard")] if "Repeater" in outside: print(f"the browser card is a delegate of the per-volume Repeater ({ancestors})", file=sys.stderr) raise SystemExit(1) PY # And the visibility that gates it is about browsing, not about a card being # open. Written out because `volumeCard.open` was exactly the expression that # made the button do nothing. grep -qE 'visible:.*volumeCard\.open.*browsing' <<<"$page_code" \ && fail 'the browser still renders only while the volume card it came from is expanded' grep -qE 'visible:.*(browsingOpen|Snapshots\.browsingConfig)' <<<"$page_code" \ || fail 'nothing on the page is shown because a snapshot is being browsed' # ── 6. Retention is editable, and the page is what edits it ───────────────── # # `Snapshots.setRetention` existed with no caller for three phases: the Keep row # printed "24 hourly, 7 daily, 4 weekly" and there was no way to change any of # them. A service function nobody calls is not a feature. grep -Fq 'Snapshots.setRetention(' <<<"$page_code" \ || fail 'the page never calls setRetention, so the keep counts are still read-only' for horizon in Hourly Daily Weekly; do grep -Fq "\"$horizon\"" <<<"$page_code" \ || fail "the page has no $horizon control, so that horizon cannot be edited" done grep -q 'function setRetention(config: string, hourly: int, daily: int, weekly: int): void' "$service" \ || fail 'the service does not take the three horizons separately' # The helper validates them. Run against a snapper that records and does # nothing: this is the only way to exercise a write verb without changing how # this machine keeps its snapshots. work="$(mktemp -d /tmp/panama-snapshots-contract.XXXXXX)" trap 'rm -rf "$work"' EXIT mkdir -p "$work/bin" cat >"$work/bin/snapper" <>"$work/calls" exit 0 STUB chmod +x "$work/bin/snapper" : >"$work/calls" resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" bash -c 'command -v snapper')" [[ "$resolved" == "$work/bin/snapper" ]] \ || fail "snapper resolves to '$resolved', not the stub; refusing to run a write verb against the real one" runh() { env -i PATH="$work/bin:/usr/bin:/bin" HOME="$work" LANG=C LC_ALL=C "$helper" "$@" } # This helper answers a refusal the way the page reads one: fresh state with an # `error` in it, not an exit code. So a refusal is checked from that field, and # from the absence of a write in the log -- the helper reads the configuration # list on its way back out either way, and counting "did anything run" would # mistake that read for the write it refused to do. retention_error() { runh "$@" 2>/dev/null | jq -r '.error // ""'; } wrote() { grep -c 'set-config' "$work/calls"; } : >"$work/calls" [[ -z "$(retention_error set-retention home 24 7 4)" ]] \ || fail 'setting the keep counts failed against the stub' grep -Eq 'snapper -c home set-config .*TIMELINE_LIMIT_HOURLY=24' "$work/calls" \ || fail "the hourly count did not reach snapper: $(cat "$work/calls")" grep -Eq 'TIMELINE_LIMIT_DAILY=7' "$work/calls" \ || fail "the daily count did not reach snapper: $(cat "$work/calls")" grep -Eq 'TIMELINE_LIMIT_WEEKLY=4' "$work/calls" \ || fail "the weekly count did not reach snapper: $(cat "$work/calls")" # One command, not three: a partial write would leave the three horizons # disagreeing with what the page shows. [[ "$(wrote)" == "1" ]] \ || fail "the three horizons were written separately: $(cat "$work/calls")" # The horizons have a ceiling, and it is the same number in the helper and in # the service. A dropdown that offers a value the helper refuses is a dropdown # that fails after the user has chosen. : >"$work/calls" [[ -z "$(retention_error set-retention home 50 50 50)" ]] \ || fail 'the highest keep count the page offers was refused by the helper' : >"$work/calls" [[ -n "$(retention_error set-retention home 51 7 4)" ]] \ || fail 'a keep count above the ceiling was accepted' [[ "$(wrote)" == "0" ]] \ || fail "a keep count above the ceiling still reached snapper: $(cat "$work/calls")" grep -q 'retentionMax' "$service" \ || fail 'the service does not publish the ceiling, so the page has to keep a second copy of it' [[ "$(grep -oE 'retentionMax[^0-9]*[0-9]+' "$service" | grep -oE '[0-9]+' | head -1)" == "50" ]] \ || fail 'the service and the helper disagree about how high the keep counts go' # Each horizon is checked before anything is written. Checking that nothing was # written is what says the refusal came first: snapper would refuse most of # these too, so "did it error" alone would pass with the validation deleted. for bad in 'abc' '-1' '5.5' '' '1e3' '99999' '7; reboot'; do for position in 1 2 3; do case "$position" in 1) arguments=("$bad" 7 4) ;; 2) arguments=(24 "$bad" 4) ;; 3) arguments=(24 7 "$bad") ;; esac : >"$work/calls" [[ -n "$(retention_error set-retention home "${arguments[@]}")" ]] \ || fail "an impossible keep count was accepted in position $position: ${bad@Q}" [[ "$(wrote)" == "0" ]] \ || fail "a refused keep count still reached snapper: ${bad@Q}: $(cat "$work/calls")" done done : >"$work/calls" [[ -n "$(retention_error set-retention '../../etc' 24 7 4)" ]] \ || fail 'a configuration name that is not one was accepted' [[ "$(wrote)" == "0" ]] || fail "a refused configuration still reached snapper: $(cat "$work/calls")" printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback, retention editable)\n' \ "$(jq '.configs | length' <<<"$snapshot")" \ "$(jq '[.configs[].snapshots[]?] | length' <<<"$snapshot")"