301 lines
15 KiB
Bash
Executable File
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")"
|