Files

515 lines
26 KiB
Bash
Executable File

#!/usr/bin/env bash
# Storage has to be honest about three things, and each of them is a way the
# page could quietly lie:
#
# 1. btrfs subvolumes mounted at / and /home are ONE filesystem sharing one
# pool of free space. df reports them as two, and a page that copies df
# shows the user twice the free space they have.
# 2. zram is a block device and is not storage. Counting it as a drive
# overstates how much disk this machine has by 8 GB.
# 3. unmount and eject must refuse anything that is not removable. The UI is
# the thing asking, and a UI can be wrong.
#
# The expensive folder scan is NOT exercised here: measuring a terabyte-scale
# library is not something a test should do on the machine someone is using.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-disks"
service="$repo_dir/config/dot/quickshell/services/Disks.qml"
page="$repo_dir/config/dot/quickshell/modules/settings/StoragePage.qml"
fail() {
printf 'disks contract: %s\n' "$1" >&2
exit 1
}
[[ -x "$helper" ]] || fail 'panama-disks is missing or not executable'
[[ -r "$service" ]] || fail 'the Disks service is missing'
[[ -r "$page" ]] || fail 'StoragePage is missing'
# ── The service reports failure rather than inventing data ───────────────────
grep -q 'pragma Singleton' "$service" || fail 'Disks is not a singleton'
grep -q 'property string lastError' "$service" || fail 'Disks cannot report a failure'
grep -q 'function refresh(): void' "$service" || fail 'Disks has no refresh'
grep -q 'function scan(): void' "$service" || fail 'Disks has no folder scan'
grep -qE 'command\s*:\s*"' "$service" && fail 'Process command must be an argument array'
# The cleanup path, service side. `clean` takes ONE id and refuses one it has
# not been shown, so a mis-wired button cannot free something the user never
# looked at -- and the helper refuses it again, because a service is not a
# security boundary.
grep -q 'function clean(identifier: string): void' "$service" \
|| fail 'Disks cannot clean one named thing'
grep -q 'root.cleanables.some(item' "$service" \
|| fail 'the service passes an id straight through without checking it is one it offered'
grep -qE 'function measureBreakdown|function measureCleanables' "$service" \
|| fail 'the breakdown and the cleanup list are not measured on demand'
# Both walks are asked for, never done on open: they cost the same as the
# folder scan the page already refuses to start by itself.
grep -qE 'Component\.onCompleted:.*Disks\.(scan|measureBreakdown|measureCleanables)\b' "$page" \
&& fail 'the page starts an expensive walk the moment it opens'
# The expensive read must not run on open; that is the entire reason it is a
# separate command.
grep -q 'Component.onCompleted: Disks.refresh()' "$page" \
|| fail 'the page does not read the cheap snapshot when it opens'
grep -q 'Component.onCompleted: Disks.scan()' "$page" \
&& fail 'the page starts the expensive folder walk on open'
# Partitioning stays out, deliberately and visibly.
grep -qiE 'mkfs|sfdisk|parted|wipefs|dd if=' "$helper" \
&& fail 'the helper can partition or format, which this page deliberately does not do'
# ── Snapshot shape ───────────────────────────────────────────────────────────
snapshot="$("$helper" snapshot)" || fail 'snapshot failed'
command -v jq >/dev/null 2>&1 || { printf 'disks contract: SKIP (no jq)\n'; exit 0; }
jq -e '(.drives | type == "array") and (.filesystems | type == "array") and (.swap | type == "array")' \
<<<"$snapshot" >/dev/null || fail 'snapshot is missing one of drives, filesystems, or swap'
# zram is swap, never a drive.
jq -e '[.drives[].name | startswith("zram")] | any | not' <<<"$snapshot" >/dev/null \
|| fail 'zram is reported as a drive, which overstates how much disk this machine has'
jq -e '[.swap[].kind] | index("zram") != null or (. | length == 0)' <<<"$snapshot" >/dev/null \
|| fail 'zram is not reported as swap'
# One entry per DEVICE, not per mount point. Two entries for one device would
# be the doubled-free-space bug.
duplicates="$(jq -r '[.filesystems[].device] | group_by(.) | map(select(length > 1)) | flatten | join(", ")' <<<"$snapshot")"
[[ -z "$duplicates" ]] \
|| fail "these devices appear more than once, so their free space is counted twice: $duplicates"
# Every filesystem has to carry the numbers the page draws.
jq -e '[.filesystems[] | (.sizeBytes > 0) and (.mountpoints | length > 0)] | all' <<<"$snapshot" >/dev/null \
|| fail 'a filesystem is missing its size or its mount points'
# A shared filesystem lists "/" first, because that is what it is called.
jq -e '[.filesystems[] | select(.mountpoints | index("/")) | .mountpoints[0] == "/"] | all' \
<<<"$snapshot" >/dev/null || fail 'the root filesystem does not lead with "/"'
# ── Refusals, against a recorded tree ────────────────────────────────────────
# Never against real hardware. An earlier version of this test ran unmount on
# whatever device happened to hold the root filesystem and passed only because
# the kernel refused -- which tests the kernel, not the helper, and would have
# unmounted /home the moment nothing held it open.
work="$(mktemp -d /tmp/panama-disks.XXXXXX)"
trap 'rm -rf "$work"' EXIT
cat >"$work/tree.json" <<'FIXTURE'
{"blockdevices":[
{"name":"nvme0n1","path":"/dev/nvme0n1","size":2000398934016,"type":"disk","fstype":null,
"mountpoints":[],"model":"Fixed Disk","serial":"FIXED1","rm":false,"hotplug":false,"rota":false,
"fssize":null,"fsused":null,"fsavail":null,
"children":[{"name":"nvme0n1p1","path":"/dev/nvme0n1p1","size":2000398934016,"type":"part",
"fstype":"btrfs","mountpoints":["/"],"model":null,"serial":null,"rm":false,"hotplug":false,
"rota":false,"fssize":2000398934016,"fsused":1000000000000,"fsavail":1000398934016}]},
{"name":"sdb","path":"/dev/sdb","size":32000000000,"type":"disk","fstype":null,
"mountpoints":[],"model":"USB Stick","serial":"USB1","rm":true,"hotplug":true,"rota":false,
"fssize":null,"fsused":null,"fsavail":null,
"children":[{"name":"sdb1","path":"/dev/sdb1","size":32000000000,"type":"part",
"fstype":"vfat","mountpoints":["/run/media/user/USB"],"model":null,"serial":null,
"rm":true,"hotplug":true,"rota":false,"fssize":32000000000,"fsused":1000000000,
"fsavail":31000000000}]}
]}
FIXTURE
# A fake udisksctl, so a refusal that should never reach the system is visible
# as an empty log rather than inferred from an exit code.
mkdir -p "$work/bin"
cat >"$work/bin/udisksctl" <<'STUB'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$PANAMA_DISKS_CALL_LOG"
STUB
chmod +x "$work/bin/udisksctl"
export PANAMA_DISKS_LSBLK="$work/tree.json"
export PANAMA_DISKS_CALL_LOG="$work/calls"
export PATH="$work/bin:$PATH"
: >"$PANAMA_DISKS_CALL_LOG"
# The root filesystem is not removable, and no argument may make it so.
"$helper" unmount /dev/nvme0n1p1 >/dev/null 2>&1 \
&& fail 'unmount accepted the root filesystem'
"$helper" eject /dev/nvme0n1 >/dev/null 2>&1 \
&& fail 'eject accepted a fixed drive'
[[ ! -s "$PANAMA_DISKS_CALL_LOG" ]] \
|| fail "a refused device still reached udisksctl: $(cat "$PANAMA_DISKS_CALL_LOG")"
"$helper" unmount 'not-a-device' >/dev/null 2>&1 \
&& fail 'unmount accepted something that is not a device path'
"$helper" unmount '/dev/../etc/passwd' >/dev/null 2>&1 \
&& fail 'unmount accepted a path escaping /dev'
"$helper" unmount '/dev/sdb99' >/dev/null 2>&1 \
&& fail 'unmount accepted a device that does not exist'
[[ ! -s "$PANAMA_DISKS_CALL_LOG" ]] \
|| fail "a rejected path still reached udisksctl: $(cat "$PANAMA_DISKS_CALL_LOG")"
# The removable one is allowed, and reaches udisksctl with separate arguments.
"$helper" unmount /dev/sdb1 >/dev/null 2>&1 \
|| fail 'unmount refused a removable partition'
grep -Fxq 'unmount -b /dev/sdb1' "$PANAMA_DISKS_CALL_LOG" \
|| fail "unmount did not reach udisksctl correctly: $(cat "$PANAMA_DISKS_CALL_LOG")"
"$helper" bogus-command >/dev/null 2>&1 \
&& fail 'an unknown command was accepted'
unset PANAMA_DISKS_LSBLK PANAMA_DISKS_CALL_LOG
# ── The breakdown adds up, and the leftover says so ──────────────────────────
#
# A stacked bar is a claim about arithmetic. Three of its four segments are
# measured (the home scan targets, the flatpak sizes, ~/.cache) and the fourth
# is whatever is left of the used space -- system files, package caches, logs,
# everything nobody itemized. That last segment is the honest one only while it
# is computed as the remainder and captioned as the remainder. Two ways it lies:
#
# * measured parts that overlap or overshoot, so the segments sum past the
# used space and the remainder goes negative (drawn as zero, silently);
# * a remainder captioned "System", which invites the user to believe the
# desktop is using 400 GB when most of it is their own unscanned files.
#
# SAFETY: this half runs the helper under `env -i` with HOME and XDG_CACHE_HOME
# inside the scratch tree and the block device tree read from the fixture, so
# every path it measures is one this file created. The first assertion below is
# the proof: the numbers it returns have to be the fixture's numbers, or the
# contract stops before anything else runs.
# No absolute home anywhere: the measurements have to follow HOME, which is the
# only reason pointing it at a fixture works.
grep -n '"/home/' "$helper" \
&& fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture'
for verb in breakdown cleanables clean; do
grep -q "\"$verb\"" "$helper" || fail "the helper has no $verb command"
done
# The applications segment is measured by walking the flatpak install roots, and
# deciding which of them counts needs "is this the same filesystem as home".
# st_dev is the obvious test and the wrong one: btrfs gives every subvolume its
# own device number, so / and /home compare as different filesystems on this
# machine and the system-wide flatpak installation drops out of the bar. The
# question is which block device is behind the path.
grep -q 'def same_filesystem' "$helper" \
|| fail 'nothing decides whether a flatpak root is on the same filesystem as home'
grep -qE 'findmnt.*SOURCE|"SOURCE"' "$helper" \
|| fail 'the filesystem behind a path is not read from findmnt, so btrfs subvolumes will compare as separate drives'
# One dnf invocation in the whole helper, and it drops downloads. This is the
# only place in Panama's settings surface that runs dnf at all.
python3 - "$helper" <<'PY' || fail 'panama-disks runs dnf for something other than dropping downloads'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
commands = []
for node in ast.walk(tree):
if not isinstance(node, (ast.List, ast.Tuple)):
continue
literals = [element.value for element in node.elts
if isinstance(element, ast.Constant) and isinstance(element.value, str)]
if "dnf" in literals or "yum" in literals or "rpm" in literals:
commands.append(literals)
if len(commands) != 1:
print(f"expected exactly one package-manager command, found {commands}", file=sys.stderr)
raise SystemExit(1)
command = commands[0]
if command[:4] != ["pkexec", "dnf", "clean", "packages"]:
print(f"the one dnf command is {command}", file=sys.stderr)
raise SystemExit(1)
PY
fixture_home="$work/home"
fixture_cache="$fixture_home/.cache"
mkdir -p "$fixture_cache/one" "$fixture_cache/two" "$work/outside"
# A distinctive size: 12 files of 111111 bytes. The point is that this cannot be
# confused with the real ~/.cache, which is orders of magnitude larger.
for index in $(seq 1 12); do
head -c 111111 /dev/zero >"$fixture_cache/one/file-$index"
done
printf 'this file is not inside the cache and must survive\n' >"$work/outside/precious"
ln -s "$work/outside" "$fixture_cache/escape-hatch"
mkdir -p "$work/bin2"
: >"$work/calls2"
for stubbed in gio flatpak pkexec dnf podman; do
cat >"$work/bin2/$stubbed" <<STUB
#!/usr/bin/env bash
printf '$stubbed %s\n' "\$*" >>"$work/calls2"
exit 0
STUB
done
chmod +x "$work/bin2"/*
runh() {
env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
XDG_CONFIG_HOME="$work/xdg-config" \
XDG_DATA_HOME="$work/xdg-data" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
calls2() { cat "$work/calls2"; }
breakdown="$(runh breakdown 2>/dev/null)" || fail 'breakdown failed against the fixture'
jq -e '(.segments | type == "object") and has("usedBytes") and has("totalBytes")
and has("complete") and has("exceedsUsed")' <<<"$breakdown" >/dev/null \
|| fail "breakdown is missing its segments or the numbers they are drawn against: $breakdown"
jq -e '[.segments.home, .segments.applications, .segments.caches, .segments.system, .segments.free]
| map(type == "number" and . >= 0) | all' <<<"$breakdown" >/dev/null \
|| fail "a breakdown segment is missing, negative, or not a number: $breakdown"
# The proof that this ran against the fixture rather than against the folders
# somebody is using: the home it measured is the one this file created, and the
# cache segment is the 12 x 111111 bytes written into it a moment ago. Nothing
# below runs until both are true.
[[ "$(jq -r '.path' <<<"$breakdown")" == "$fixture_home" ]] \
|| fail "breakdown measured $(jq -r '.path' <<<"$breakdown"), not the fixture home; refusing to go on"
jq -e '.segments.caches > 1200000 and .segments.caches < 1500000' <<<"$breakdown" >/dev/null \
|| fail "the cache segment is not the fixture's cache: $(jq -r .segments.caches <<<"$breakdown")"
# The arithmetic. Three segments are measured and the fourth is what is left of
# the used space; they have to add up to exactly the used space, or the bar is
# drawn against a total nobody has.
jq -e '(.segments.home + .segments.applications + .segments.caches + .segments.system) == .usedBytes' \
<<<"$breakdown" >/dev/null \
|| fail "the segments do not add up to the used space: $breakdown"
jq -e '.segments.free == .freeBytes and (.usedBytes + .freeBytes) <= .totalBytes' <<<"$breakdown" >/dev/null \
|| fail "the free segment and the filesystem disagree: $breakdown"
# The two ways the measurement can be wrong are reported rather than absorbed
# into the remainder: a walk that ran out of time, and parts that overlap.
jq -e '.exceedsUsed == false' <<<"$breakdown" >/dev/null \
|| fail "the fixture's measured parts overshot its used space, so this run proves nothing: $breakdown"
grep -Fq 'Disks.breakdown.complete === false' "$page" \
|| fail 'the page does not say when the walk ran out of time, so floors are drawn as totals'
grep -Fq 'Disks.breakdown.exceedsUsed === true' "$page" \
|| fail 'the page does not say when the measured parts overlap, so a clamped remainder looks measured'
# The remainder is named for what it is. "System" alone would blame the desktop
# for the user's own unscanned files.
page_text="$(grep -vE '^\s*//' "$page")"
grep -Fq 'System & everything else' <<<"$page_text" \
|| fail 'the remainder segment is captioned as if it were all system files'
# ── Every cleanable is itemized, sized, and inert until asked for ────────────
cleanables="$(runh cleanables 2>/dev/null)" || fail 'cleanables failed against the fixture'
jq -e 'type == "array" and length > 0' <<<"$cleanables" >/dev/null \
|| fail "cleanables reported nothing at all: $cleanables"
jq -e '[.[] | has("id") and has("label") and has("detail") and has("bytes") and has("privileged")] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable is missing its id, label, honest detail, size, or privilege flag: $cleanables"
jq -e '[.[] | (.bytes | type == "number") and .bytes >= 0 and (.label | length > 0) and (.detail | length > 0)] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable has no size or no explanation of what it costs to remove: $cleanables"
unknown_ids="$(jq -r '[.[].id] - ["cache", "trash", "flatpak-unused", "dnf-cache"] | join(", ")' \
<<<"$cleanables")"
[[ -z "$unknown_ids" ]] \
|| fail "a cleanable id nothing else knows about: $unknown_ids"
# Nothing arrives pre-selected. A cleanup list that ships with boxes ticked is
# the racket this card exists not to be: the user should have to say yes to each
# thing, individually, having seen what it costs.
jq -e '[.[] | (has("selected") or has("checked") or has("default")) | not] | all' \
<<<"$cleanables" >/dev/null \
|| fail "a cleanable carries a pre-selected state: $cleanables"
jq -e '[.[] | select(.id == "dnf-cache") | .privileged] | all and length > 0' <<<"$cleanables" >/dev/null \
|| fail 'the package cache is not marked as needing a password, so the page cannot warn about it'
jq -e '[.[] | select(.id == "cache" or .id == "trash") | .privileged | not] | all' <<<"$cleanables" >/dev/null \
|| fail 'clearing your own cache or trash is marked as privileged, which would ask for a password it does not need'
# The unused-runtime size is not measured here: it is asked of the applications
# helper, which is the thing that removes them. Two ideas of "unused" would show
# one number and free another.
jq -e '[.[] | select(.id == "flatpak-unused") | .detail | test("flatpak decides")] | all and length > 0' \
<<<"$cleanables" >/dev/null \
|| fail 'the unused-runtime row does not say that flatpak has the final word on the list'
grep -q 'PANAMA_APPLICATIONS_HELPER' "$helper" \
|| fail 'the flatpak-unused row cannot be pointed at the applications helper, so the two cannot be kept in step'
cat >"$work/bin2/apps-helper" <<'STUB'
#!/usr/bin/env bash
printf 'apps-helper %s\n' "$*" >>"$PANAMA_DISKS_CALL_LOG"
case "$1" in
unused-runtimes) printf '[{"id":"org.example.Old","sizeBytes":777000},{"id":"org.example.Older","sizeBytes":3000}]\n' ;;
*) printf '[]\n' ;;
esac
exit 0
STUB
chmod +x "$work/bin2/apps-helper"
: >"$work/calls2"
borrowed="$(env -i \
PATH="$work/bin2:/usr/bin:/bin" \
HOME="$fixture_home" \
XDG_CACHE_HOME="$fixture_cache" \
PANAMA_DISKS_LSBLK="$work/tree.json" \
PANAMA_DISKS_CALL_LOG="$work/calls2" \
PANAMA_APPLICATIONS_HELPER="$work/bin2/apps-helper" \
LANG=C LC_ALL=C \
"$helper" cleanables 2>/dev/null)"
grep -Fq 'apps-helper unused-runtimes' "$work/calls2" \
|| fail "the unused-runtime size was computed here rather than asked of the applications helper: $(calls2)"
[[ "$(jq -r '.[] | select(.id == "flatpak-unused") | .bytes' <<<"$borrowed")" == "780000" ]] \
|| fail "the row does not report what the applications helper said was unused: $borrowed"
cache_bytes="$(jq -r '.[] | select(.id == "cache") | .bytes' <<<"$cleanables")"
[[ "$cache_bytes" -gt 1200000 && "$cache_bytes" -lt 1500000 ]] \
|| fail "the cache cleanable does not describe the fixture cache, so it is measuring something else: $cache_bytes"
# ── Nothing runs without its own id ──────────────────────────────────────────
#
# Checked from the log rather than the exit code: a refusal that happens after
# the command ran is not a refusal.
for bad in '' 'all' '*' '../../' 'cache trash' 'CACHE' 'dnf-cache; reboot'; do
: >"$work/calls2"
runh clean "$bad" >/dev/null 2>&1 \
&& fail "clean accepted an id that is not a cleanable: ${bad@Q}"
[[ ! -s "$work/calls2" ]] \
|| fail "a refused clean still ran something: ${bad@Q}: $(calls2)"
done
: >"$work/calls2"
runh clean >/dev/null 2>&1 && fail 'clean with no id at all was accepted'
[[ ! -s "$work/calls2" ]] || fail "clean with no id still ran something: $(calls2)"
grep -qE '"clean-all"|"clean_everything"|--all' "$helper" \
&& fail 'the helper offers a way to clean everything at once, which nobody asked for item by item'
# ── Each cleanable does its own one thing ────────────────────────────────────
: >"$work/calls2"
runh clean trash >/dev/null 2>&1
grep -Eq '^gio trash .*--empty|^gio trash --empty' "$work/calls2" \
|| fail "emptying the trash does not go through gio, which is the only thing that knows where it is: $(calls2)"
grep -qE '^(rm|find) ' "$work/calls2" \
&& fail "the trash was emptied with rm rather than gio: $(calls2)"
: >"$work/calls2"
runh clean flatpak-unused >/dev/null 2>&1
grep -Eq '^flatpak uninstall .*--unused' "$work/calls2" \
|| fail "clearing unused runtimes does not reach flatpak: $(calls2)"
grep -Eq '^flatpak uninstall .*--noninteractive' "$work/calls2" \
|| fail "clearing unused runtimes would stop for a prompt nobody can answer: $(calls2)"
: >"$work/calls2"
runh clean dnf-cache >/dev/null 2>&1
grep -Fq 'pkexec dnf clean packages' "$work/calls2" \
|| fail "clearing the package cache is not the polkit-wrapped drop of downloaded rpms: $(calls2)"
grep -Fq 'dnf clean all' "$work/calls2" \
&& fail "the package METADATA was dropped too, which frees little and slows the next install: $(calls2)"
grep -qE '^dnf ' "$work/calls2" \
&& fail "the helper ran dnf directly instead of going through pkexec: $(calls2)"
grep -qE 'remove|erase|autoremove' "$work/calls2" \
&& fail "clearing the package cache removes packages: $(calls2)"
# ── Clearing the cache stays inside the cache ────────────────────────────────
#
# The dangerous one. ~/.cache collects symlinks -- Steam, Electron applications
# and language toolchains all put them there -- and an rm that follows one
# deletes whatever it points at. The fixture plants exactly that: a link out of
# the cache to a file that must survive.
#
# Running this is safe because of the two assertions above: the helper reported
# the fixture's byte count, so the directory it is about to empty is the one
# this file created.
: >"$work/calls2"
runh clean cache >/dev/null 2>&1 || fail 'clearing the cache failed against the fixture'
[[ -f "$work/outside/precious" ]] \
|| fail 'clearing the cache followed a symlink out of it and deleted a file elsewhere'
[[ -d "$work/outside" ]] \
|| fail 'clearing the cache deleted a directory outside the cache'
remaining="$(find "$fixture_cache" -type f | wc -l)"
[[ "$remaining" == "0" ]] \
|| fail "clearing the cache left $remaining file(s) behind, so it did not do what it said"
[[ -d "$fixture_cache" ]] \
|| fail 'clearing the cache removed the cache directory itself, which applications expect to exist'
# ── The cleanup card does not sell anything ──────────────────────────────────
#
# Every "clean my PC" product on earth manufactures urgency, and the difference
# between this card and those is entirely a matter of copy. Pinned as an
# absence, in the card itself, because that is where the pressure would go.
python3 - "$page" <<'PY' || fail 'the cleanup card uses the language of a cleaner racket'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//"))
def block_at(start: int) -> str:
depth = 0
for index in range(text.find("{", start), len(text)):
if text[index] == "{":
depth += 1
elif text[index] == "}":
depth -= 1
if depth == 0:
return text[start:index + 1]
return ""
# The tightest card whose own title is the cleanup one: an outer card that
# merely contains it would drag the whole page's copy into the check.
cards = [block for block in (block_at(match.start())
for match in re.finditer(r"SettingsCard \{", text))
if re.search(r"title:[^\n]*Clean up", block)]
if not cards:
print("there is no cleanup card on the Storage page", file=sys.stderr)
raise SystemExit(1)
card = min(cards, key=len)
# Only what the user reads. QML is full of exclamation marks and none of them
# are shouting at anybody.
copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower()
PRESSURE = [
"running out", "running low", "act now", "recommended", "we recommend",
"urgent", "boost", "speed up", "optimize", "optimise", "reclaim now",
"free up now", "clean now", "junk", "safe to remove", "you should",
"needs attention", "!",
]
found = [phrase for phrase in PRESSURE if phrase in copy]
if found:
print(f"the cleanup card says: {found}", file=sys.stderr)
raise SystemExit(1)
PY
# Nor does it decide for the user: every row is its own two-stage confirm, and
# there is no button that clears the lot.
grep -qiE '"(Clean everything|Clean all|Free up space|Optimize)"' "$page" \
&& fail 'the cleanup card offers a single button that clears everything'
grep -Fq 'nothing to do' <<<"$page_text" \
|| fail 'a cleanable with nothing in it does not say so, so the row invites a pointless confirm'
# The breakdown bar is a component, and a component the page draws has to be
# registered or the page does not load at all -- which reads as an empty tab
# rather than as a missing line in a qmldir.
grep -q '^StorageBreakdownBar 1\.0 StorageBreakdownBar\.qml$' \
"$repo_dir/config/dot/quickshell/modules/settings/qmldir" \
|| fail 'the breakdown bar is not registered in the Settings module'
# ── One affordance for container space, not two ──────────────────────────────
#
# The Storage page used to offer "Unused container images" as a row that opened
# a terminal running `podman system df`, directly above a Containers card that
# reclaims the same space properly. Two buttons for one job, one of which is a
# terminal window.
grep -Fq 'Unused container images' "$page" \
&& fail 'the duplicate container-images row is back, above the card that already does this'
grep -Fq 'kitty' "$page" \
&& fail 'the Storage page opens a terminal, which is not a settings page doing its job'
printf 'disks contract: PASS (%d drives, %d filesystems, breakdown adds up, %d cleanable(s))\n' \
"$(jq '.drives | length' <<<"$snapshot")" \
"$(jq '.filesystems | length' <<<"$snapshot")" \
"$(jq 'length' <<<"$cleanables")"