515 lines
26 KiB
Bash
Executable File
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)' "$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")"
|