Files
Panama/tests/quickshell/snapshots-contract
T

301 lines
15 KiB
Bash
Executable File

#!/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" <<STUB
#!/usr/bin/env bash
printf 'snapper %s\n' "\$*" >>"$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")"