Make Applications a real app manager, and clean up storage without the racket

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 17:18:14 -04:00
parent b30bf40407
commit 5a0643357f
29 changed files with 5024 additions and 314 deletions
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env bash
# The Applications page can now install and remove software, which makes
# `panama-applications` the most dangerous helper in the shell. Three things
# have to hold, and each of them is a way a settings page could damage a machine
# rather than manage it:
#
# 1. The catalog is the ONLY thing installable. A page that took an id from
# anywhere else would be a general-purpose package installer wearing a
# settings icon, and `pkexec dnf install -y $anything` is as bad as that
# sounds. So an id the catalog does not contain is refused before any
# command runs.
# 2. The catalog the page reads is the SAME catalog `panama apps` and the
# installer read. Two parsers over one file format drift, and the drift is
# invisible: the page simply offers a slightly different list, or installs
# a slightly different target. This contract runs both parsers over the
# same files and compares the answers rather than reading either one.
# 3. Removal is flatpak and nothing else. Removing a dnf package from a
# settings page can take the desktop, the compositor, or the kernel with
# it -- Settings says so honestly and prints the command instead. That is
# only true while no removal path exists at all, so it is checked as an
# absence in the source, not as a refusal at runtime: a refusal can be
# bypassed by a later caller; a path that does not exist cannot.
#
# SAFETY. This runs the real helper against a fake machine, so it must be
# impossible for it to reach the real one. Verified before anything runs:
#
# 1. every binary the helper names is resolved through PATH (asserted
# statically -- an absolute /usr/bin/flatpak would walk straight past the
# stubs);
# 2. PATH's first entry is the stub directory, and flatpak, rpm, dnf, pkexec
# and gio each resolve there;
# 3. the helper is run under `env -i` with HOME and every XDG directory inside
# the scratch tree, so anything it writes lands there;
# 4. dnf, sudo and rpm-ostree are stubs that record and FAIL, so a real
# package transaction is not merely unlikely, it exits non-zero and is
# visible in the log.
#
# The catalog itself is a fixture, not `setup/packages/extras`: the point is to
# exercise the format's corners (a labelled entry, an unlabelled one, a dnf
# name, comments, blank lines, and indented continuations) rather than whatever
# the shipped catalog happens to contain this week.
#
# Set PANAMA_APPLICATIONS_STATIC_ONLY=1 to run only the source-reading half,
# which touches nothing at all.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-applications"
service="$repo_dir/config/dot/quickshell/services/AppLibrary.qml"
catalog_lib="$repo_dir/setup/lib/extras-catalog"
fail() {
printf 'app library contract: %s\n' "$1" >&2
exit 1
}
for path in "$helper" "$service" "$catalog_lib"; do
[[ -r "$path" ]] || fail "missing $path"
done
[[ -x "$helper" ]] || fail 'panama-applications is not executable'
# ── Static: the helper cannot walk past the stubs ────────────────────────────
#
# Checked first because the dynamic half's safety rests on it.
absolute="$(grep -nE '"/(usr/)?s?bin/[a-z0-9-]+"' "$helper")"
[[ -z "$absolute" ]] \
|| fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute"
# ── Static: there is no removal path for system packages ─────────────────────
#
# The page's honest refusal row is a promise that Settings cannot do this. The
# promise is kept by the code not existing. `rpm` is allowed, but only to ask a
# question: -q and nothing else.
python3 - "$helper" <<'PY' || fail 'the helper can remove a system package'
import ast
import sys
source = open(sys.argv[1], encoding="utf-8").read()
tree = ast.parse(source)
REMOVAL = {"remove", "erase", "autoremove", "-e", "--erase", "history", "rollback"}
findings = []
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)]
words = set(literals)
if {"dnf", "rpm-ostree", "yum"} & words:
offending = words & REMOVAL
if offending:
findings.append(f"line {node.lineno}: dnf command with {sorted(offending)}")
if "rpm" in words:
# A query is a question. Anything else is a transaction.
if not ({"-q", "-qa", "--query"} & words) or (words & REMOVAL):
findings.append(f"line {node.lineno}: rpm invoked for something other than a query: {literals}")
if findings:
print("; ".join(findings), file=sys.stderr)
raise SystemExit(1)
PY
# Even in a string that never becomes a command list. There is no reason for
# these words to be in this file, and a helper that builds its argv from a
# format string would slip past the check above.
forbidden="$(grep -nE '(dnf|rpm-ostree|yum)[^\n]{0,40}(remove|erase|autoremove)|rpm[^\n]{0,20} -e' "$helper")"
[[ -z "$forbidden" ]] \
|| fail "the helper spells out a package removal: $forbidden"
# ── Static: uninstall is flatpak, alone ──────────────────────────────────────
python3 - "$helper" <<'PY' || fail 'uninstall does not build a flatpak-only command'
import ast
import sys
tree = ast.parse(open(sys.argv[1], encoding="utf-8").read())
target = next((node for node in ast.walk(tree)
if isinstance(node, ast.FunctionDef) and node.name in ("uninstall", "uninstall_app")),
None)
if target is None:
print("no uninstall function", file=sys.stderr)
raise SystemExit(1)
OTHER = {"dnf", "rpm", "yum", "pkexec", "sudo", "rpm-ostree", "sh", "bash"}
commands = []
for node in ast.walk(target):
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 literals and literals[0] in OTHER:
print(f"line {node.lineno}: uninstall reaches for {literals[0]}", file=sys.stderr)
raise SystemExit(1)
if literals and literals[0] == "flatpak":
commands.append(literals)
if not commands:
print("uninstall never builds a flatpak command", file=sys.stderr)
raise SystemExit(1)
if not any("uninstall" in command and "--noninteractive" in command for command in commands):
print(f"uninstall is not a non-interactive flatpak uninstall: {commands}", file=sys.stderr)
raise SystemExit(1)
PY
# ── Static: the service's shape, and its seam ────────────────────────────────
grep -q 'pragma Singleton' "$service" || fail 'AppLibrary is not a singleton'
grep -q 'PANAMA_APPLICATIONS_HELPER' "$service" \
|| fail 'the service has no helper-path seam, so nothing can point it at a stub'
grep -qE 'command\s*:\s*"' "$service" \
&& fail 'Process command must be an argument array, or an application id becomes shell'
for needle in 'property string lastError' 'function permissionsFor' 'function uninstall' \
'function install' 'function entriesFor'; do
grep -q "$needle" "$service" || fail "the service is missing: $needle"
done
if [[ "${PANAMA_APPLICATIONS_STATIC_ONLY:-0}" == "1" ]]; then
printf 'app library contract: PASS (static)\n'
exit 0
fi
command -v jq >/dev/null 2>&1 || { printf 'app library contract: SKIP (no jq)\n'; exit 0; }
command -v python3 >/dev/null 2>&1 || { printf 'app library contract: SKIP (no python3)\n'; exit 0; }
# ── The fake machine ─────────────────────────────────────────────────────────
work="$(mktemp -d /tmp/panama-app-library.XXXXXX)"
stub_dir="$work/bin"
state_dir="$work/state"
home_dir="$work/home"
extras_dir="$work/extras"
mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$extras_dir" \
"$work/config" "$work/data" "$work/cache" "$work/run"
: >"$state_dir/argv"
trap 'rm -rf "$work"' EXIT
# The catalog, written to exercise the format rather than to be realistic.
# Every line here is a rule in setup/lib/extras-catalog's header comment.
cat >"$extras_dir/demo" <<'CATALOG'
# A comment, and the blank line under it, are not entries.
flatpak:org.example.Alpha | Alpha Editor
flatpak:org.example.Bravo
charlie-tool
delta-tool | Delta
flatpak:org.example.Echo | Echo Studio
flatpak:org.example.Echo.Plugin.One
flatpak:org.example.Echo.Plugin.Two
CATALOG
cat >"$extras_dir/more" <<'CATALOG'
zulu-tool
flatpak:org.example.Zulu | Zulu
CATALOG
# flatpak, recorded rather than performed. Which ids it claims are installed is
# the fixture's business, not the helper's: `installed` in the catalog has to
# come from asking, and this is what answers.
cat >"$stub_dir/flatpak" <<STUB
#!/usr/bin/env bash
printf 'flatpak %s\n' "\$*" >>"$state_dir/argv"
joined="\$*"
permissions() {
case "\$1" in
org.example.Permissive)
printf '[Context]\n'
printf 'shared=network;ipc;\n'
printf 'sockets=x11;wayland;pulseaudio;\n'
printf 'devices=all;\n'
printf 'filesystems=host;\n'
printf 'unrecognized-capability=yes;\n'
printf '[Session Bus Policy]\n'
printf 'org.freedesktop.Flatpak=talk\n' ;;
org.example.Homey)
printf '[Context]\n'
printf 'sockets=wayland;\n'
printf 'filesystems=home;\n' ;;
*)
printf '[Context]\n'
printf 'sockets=wayland;\n' ;;
esac
}
case "\$joined" in
*"--show-permissions"*)
for argument in "\$@"; do
case "\$argument" in
-*) ;;
info) ;;
*) permissions "\$argument"; exit 0 ;;
esac
done
exit 0 ;;
info*)
# Only these four are on the fake machine.
case "\$joined" in
*org.example.Alpha*|*org.example.Permissive*|*org.example.Homey*|*org.example.Sandboxed*)
exit 0 ;;
esac
printf 'error: %s not installed\n' "\$2" >&2
exit 1 ;;
list*)
printf 'org.example.Alpha\tAlpha Editor\t412.5 MB\tflathub\n'
printf 'org.example.Permissive\tPermissive\t88.1 MB\tflathub\n'
printf 'org.example.Homey\tHomey\t12.0 MB\tflathub\n'
printf 'org.example.Sandboxed\tSandboxed\t4.2 MB\tflathub\n'
exit 0 ;;
esac
exit 0
STUB
# rpm answers questions and nothing else. Anything but a query is a failure the
# log will show. One package on this fake machine is installed: charlie-tool,
# which the catalog offers, so `installed` has something true to report.
cat >"$stub_dir/rpm" <<STUB
#!/usr/bin/env bash
printf 'rpm %s\n' "\$*" >>"$state_dir/argv"
case "\$1" in
-qa|--query|-q)
printf 'charlie-tool\nbash\nkernel\n'
exit 0 ;;
esac
printf 'app library contract: rpm was asked to do something other than query\n' >&2
exit 1
STUB
# pkexec records the privileged command it was handed and runs NOTHING.
cat >"$stub_dir/pkexec" <<STUB
#!/usr/bin/env bash
printf 'pkexec %s\n' "\$*" >>"$state_dir/argv"
exit 0
STUB
# Every other way out is closed rather than left open.
for blocked in dnf yum sudo rpm-ostree gio flatpak-builder; do
cat >"$stub_dir/$blocked" <<STUB
#!/usr/bin/env bash
printf '$blocked %s\n' "\$*" >>"$state_dir/argv"
printf 'app library contract: the helper reached for $blocked\n' >&2
exit 1
STUB
done
chmod +x "$stub_dir"/*
runh() {
env -i \
PATH="$stub_dir:/usr/bin:/bin" \
HOME="$home_dir" \
XDG_CONFIG_HOME="$work/config" \
XDG_DATA_HOME="$work/data" \
XDG_CACHE_HOME="$work/cache" \
XDG_RUNTIME_DIR="$work/run" \
PANAMA_EXTRAS_DIR="$extras_dir" \
LANG=C LC_ALL=C \
"$helper" "$@"
}
# The safety claim, verified rather than assumed.
for binary in flatpak rpm dnf pkexec sudo gio; do
resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")"
[[ "$resolved" == "$stub_dir/$binary" ]] \
|| fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one"
done
# A refusal is a non-zero exit and a sentence on stderr, which is how every
# helper in this repo says no.
refused() { ! runh "$@" >/dev/null 2>&1; }
log() { cat "$state_dir/argv"; }
no_package_transaction() {
local where="$1"
grep -qE '^(dnf|yum|sudo|rpm-ostree) ' "$state_dir/argv" \
&& fail "$where reached a package manager directly: $(log)"
grep -qE '^rpm .*(-e|--erase|remove)' "$state_dir/argv" \
&& fail "$where asked rpm to remove something: $(log)"
return 0
}
# ── The catalog is read by ONE set of rules ──────────────────────────────────
#
# Both parsers, the same files, compared line for line. Reading either one to
# build the expectation would pass just as happily when both are wrong.
source "$catalog_lib"
catalog="$(runh catalog 2>/dev/null)" || fail 'catalog failed against the fixture'
jq -e '(.categories | type == "array") and (.categories | length) == 2' <<<"$catalog" >/dev/null \
|| fail "catalog did not report the two fixture categories: $catalog"
expected_categories="$(catalog_categories "$extras_dir" | sort | tr '\n' ' ')"
actual_categories="$(jq -r '.categories[] | if type == "object" then .name else . end' <<<"$catalog" \
| sort | tr '\n' ' ')"
[[ "$expected_categories" == "$actual_categories" ]] \
|| fail "the two parsers disagree about the categories: '$expected_categories' vs '$actual_categories'"
for category in demo more; do
# `target<TAB>label` from the shell library, turned into the id/label/kind
# triple the page is given. The mapping is the claim: the id is the catalog
# line's target VERBATIM -- `flatpak:` prefix and all, because that is the
# string `install` matches against -- and the prefix becomes the kind.
expected="$(catalog_entries "$extras_dir/$category" | while IFS=$'\t' read -r target label; do
if [[ "$target" == flatpak:* ]]; then
printf '%s\t%s\tflatpak\n' "$target" "$label"
else
printf '%s\t%s\tdnf\n' "$target" "$label"
fi
done)"
actual="$(jq -r --arg category "$category" '
(.entries[$category] // (.categories[] | select(.name == $category) | .entries))[]
| [.id, .label, .kind] | @tsv' <<<"$catalog" 2>/dev/null)"
[[ -n "$actual" ]] || fail "the catalog reports no entries for '$category': $catalog"
if [[ "$expected" != "$actual" ]]; then
printf 'from setup/lib/extras-catalog:\n%s\nfrom panama-applications:\n%s\n' \
"$expected" "$actual" >&2
fail "the two catalog parsers disagree about '$category'"
fi
done
# The indented lines belong to the entry above them. Listing them separately
# would put fifteen OBS plugins in the menu as if they were applications.
jq -e '[.. | objects | select(has("id")) | .id | select(contains(".Plugin."))] | length == 0' \
<<<"$catalog" >/dev/null \
|| fail 'an indented continuation line is offered as an entry of its own'
# `installed` is asked, not assumed: only what the stubs admit to is marked.
installed="$(jq -r '[.. | objects | select(has("id") and has("installed")) | select(.installed) | .id]
| sort | join(",")' <<<"$catalog")"
[[ "$installed" == "charlie-tool,flatpak:org.example.Alpha" ]] \
|| fail "installed state does not match what flatpak and rpm were willing to confirm: '$installed'"
# ── Nothing outside the catalog can be installed ─────────────────────────────
#
# The refusal must happen before any command runs, which the empty log is what
# proves. Checking only the exit code would pass with the guard deleted, since
# the stub flatpak would fail on a nonsense id anyway.
for bad in 'org.evil.Payload' 'charlie-tool; reboot' '--unused' '' '../../etc/passwd' \
'flatpak:org.example.Echo.Plugin.One' 'org.example.Bravo' 'flatpak:org.evil.Payload'; do
: >"$state_dir/argv"
refused install demo "$bad" \
|| fail "an id the catalog does not offer was accepted for install: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a refused install still ran something: ${bad@Q}: $(log)"
done
for bad_category in 'nonexistent' '../extras' '' 'demo/../more'; do
: >"$state_dir/argv"
refused install "$bad_category" flatpak:org.example.Bravo \
|| fail "an unknown catalog category was accepted: ${bad_category@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a refused category still ran something: ${bad_category@Q}: $(log)"
done
# An entry that exists, but in the other category, is still off-catalog here.
: >"$state_dir/argv"
refused install demo zulu-tool \
|| fail 'an entry from another category was installed as if it belonged to this one'
[[ ! -s "$state_dir/argv" ]] || fail "a cross-category install still ran something: $(log)"
# ── What an accepted install actually runs ───────────────────────────────────
: >"$state_dir/argv"
runh install demo flatpak:org.example.Bravo >/dev/null 2>&1
grep -Eq 'flatpak install .*--noninteractive.*flathub .*org\.example\.Bravo' "$state_dir/argv" \
|| fail "installing a Flathub entry did not reach flatpak as expected: $(log)"
no_package_transaction 'installing a flatpak'
# The extensions ride along with the entry that owns them, and nothing else
# does: an install that quietly pulled a neighbouring entry would make the
# catalog's grouping a lie.
: >"$state_dir/argv"
runh install demo flatpak:org.example.Echo >/dev/null 2>&1
grep -Fq 'org.example.Echo' "$state_dir/argv" \
|| fail "installing an entry with extensions did not install the entry: $(log)"
strays="$(grep -oE 'org\.example\.[A-Za-z.]+' "$state_dir/argv" \
| grep -vE '^org\.example\.Echo(\.Plugin\.(One|Two))?$' | sort -u | tr '\n' ' ')"
[[ -z "$strays" ]] || fail "installing one entry reached for another: $strays"
# A dnf entry goes through polkit, and dnf is never run directly.
: >"$state_dir/argv"
runh install more zulu-tool >/dev/null 2>&1
grep -Eq '^pkexec .*dnf .*install .*zulu-tool' "$state_dir/argv" \
|| fail "installing a package entry did not go through pkexec: $(log)"
grep -qE '^dnf ' "$state_dir/argv" \
&& fail "the helper ran dnf directly instead of asking polkit first: $(log)"
# ── Removal is flatpak, and only for a flatpak ───────────────────────────────
: >"$state_dir/argv"
runh uninstall org.example.Alpha >/dev/null 2>&1
grep -Eq 'flatpak uninstall .*--noninteractive.*org\.example\.Alpha' "$state_dir/argv" \
|| fail "uninstall did not reach flatpak with the expected arguments: $(log)"
no_package_transaction 'uninstalling an application'
# A dnf package name is not an application id, whatever the caller believes.
: >"$state_dir/argv"
runh uninstall charlie-tool >/dev/null 2>&1
no_package_transaction 'uninstalling a system package name'
for bad in '--unused' '-y' '' 'org.example.Alpha; reboot' '../../org.example.Alpha' \
"$(printf 'a%.0s' {1..300})"; do
: >"$state_dir/argv"
refused uninstall "$bad" \
|| fail "uninstall accepted a malformed application id: ${bad@Q}"
[[ ! -s "$state_dir/argv" ]] \
|| fail "a malformed application id reached flatpak before being refused: ${bad@Q}: $(log)"
done
# ── Unused runtimes are listed without being removed ─────────────────────────
#
# The Storage page's cleanup row shows a size before anything happens, so the
# listing verb has to be a question. A `flatpak uninstall --unused` here would
# remove gigabytes at the moment the page merely rendered.
: >"$state_dir/argv"
runh unused-runtimes >/dev/null 2>&1
while read -r line; do
[[ "$line" == flatpak* ]] || continue
case "$line" in
*--dry-run*|*list*|*info*) ;;
*uninstall*) fail "listing unused runtimes actually removed them: $line" ;;
esac
done <"$state_dir/argv"
: >"$state_dir/argv"
runh clean-unused >/dev/null 2>&1
grep -Eq 'flatpak uninstall .*--unused' "$state_dir/argv" \
|| fail "clean-unused does not remove unused runtimes: $(log)"
grep -Eq 'flatpak uninstall .*--noninteractive' "$state_dir/argv" \
|| fail "clean-unused would stop for a prompt nobody can answer: $(log)"
# ── Permissions are summarized, and nothing is dropped ───────────────────────
#
# The buckets are the point: "filesystems=host" and "filesystems=home" are one
# character apart in the metadata and worlds apart in what they mean, so the
# summary has to tell them apart rather than reporting "file access".
permissive="$(runh permissions org.example.Permissive 2>/dev/null)" \
|| fail 'permissions failed for an installed application'
jq -e '(.summary | type == "array") and has("raw")' <<<"$permissive" >/dev/null \
|| fail "permissions is missing its summary or its raw metadata: $permissive"
summary_of() { jq -r '.summary | join(" | ")' <<<"$1"; }
permissive_summary="$(summary_of "$permissive")"
for bucket in 'Full file system access' 'Network' 'Devices' 'Camera' 'Microphone'; do
grep -Fq "$bucket" <<<"$permissive_summary" \
|| fail "a permission the application really has is missing from the summary ($bucket): $permissive_summary"
done
# Unknown keys are summarized honestly rather than dropped: a Flatpak feature
# added next year must not silently become "this application asks for nothing".
grep -Fq 'unrecognized-capability' <<<"$permissive" \
|| fail 'a permission key the helper does not recognize disappeared entirely'
homey="$(runh permissions org.example.Homey 2>/dev/null)" \
|| fail 'permissions failed for a home-folder application'
homey_summary="$(summary_of "$homey")"
grep -Fq 'Home folder' <<<"$homey_summary" \
|| fail "an application with home access is not described as having it: $homey_summary"
grep -Fq 'Full file system access' <<<"$homey_summary" \
&& fail "home access is being reported as access to the whole filesystem: $homey_summary"
# And the summary is derived, not decorative: a sandboxed application says less.
sandboxed="$(runh permissions org.example.Sandboxed 2>/dev/null)"
sandboxed_summary="$(summary_of "$sandboxed")"
for bucket in 'Full file system access' 'Network' 'Camera'; do
grep -Fq "$bucket" <<<"$sandboxed_summary" \
&& fail "a sandboxed application is credited with $bucket: $sandboxed_summary"
done
[[ "$(jq '.summary | length' <<<"$permissive")" -gt "$(jq '.summary | length' <<<"$sandboxed")" ]] \
|| fail 'the permissive and the sandboxed application summarize the same, so nothing is being read'
for bad in '' '--show-permissions' 'org.example.Alpha; reboot' '../escape'; do
: >"$state_dir/argv"
refused permissions "$bad" \
|| fail "permissions accepted a malformed application id: ${bad@Q}"
done
# ── The installed list carries what the rows draw ────────────────────────────
flatpaks="$(runh flatpaks 2>/dev/null)" || fail 'flatpaks failed against the stub'
jq -e 'type == "array" and length == 4' <<<"$flatpaks" >/dev/null \
|| fail "flatpaks did not report the four installed applications: $flatpaks"
jq -e '[.[] | has("id") and has("name") and has("size") and has("origin")] | all' \
<<<"$flatpaks" >/dev/null || fail "an installed application is missing part of its shape: $flatpaks"
jq -e '[.[] | (.id | length > 0) and (.name | length > 0) and (.origin | length > 0)] | all' \
<<<"$flatpaks" >/dev/null || fail "an installed application has an empty field: $flatpaks"
jq -e '[.[].id] | index("org.example.Alpha") != null' <<<"$flatpaks" >/dev/null \
|| fail "the machine-readable listing was not parsed into ids: $flatpaks"
# The size arrives as flatpak's own words ("412.5 MB") and as bytes beside them.
# The bytes may be null -- a flatpak that cannot say is better than a number
# invented for the sake of having one -- but never a string the page would then
# have to parse a second time.
jq -e '[.[].sizeBytes | type] | all(. == "number" or . == "null")' <<<"$flatpaks" >/dev/null \
|| fail "an installed size is neither a number of bytes nor honestly absent: $flatpaks"
jq -e '[.[] | select(.size == "412.5 MB") | .sizeBytes] | first > 400000000' <<<"$flatpaks" >/dev/null \
|| fail "flatpak's own size was not parsed into bytes: $flatpaks"
refused bogus-verb || fail 'an unknown command was accepted'
# ── Nothing anywhere ran a package transaction ───────────────────────────────
: >"$state_dir/argv"
runh catalog >/dev/null 2>&1
runh flatpaks >/dev/null 2>&1
no_package_transaction 'reading the catalog and the installed list'
printf 'app library contract: PASS (catalog agreement, install refusals, flatpak-only removal, permission buckets)\n'
+213 -34
View File
@@ -1,5 +1,22 @@
#!/usr/bin/env bash
# The Applications page manages applications now, rather than only pointing file
# types at them. What is pinned here is the part that is easy to get subtly
# wrong and impossible to notice:
#
# * the role matcher, which decides which applications a role may be set to.
# It is extracted from the page and run against fixtures, because every bug
# it has ever had was silent -- a role that offered nothing but the
# application it already had, and nobody could tell whether that was the
# matcher or the machine;
# * removal being honest: Flatpak applications come off from here, system
# packages do not, and the row says the command instead of pretending;
# * the things a page can quietly lose in a rebuild -- the escape hatch for a
# single file type, the read-only compositor autostart, the launcher chord
# that is read rather than hardcoded.
#
# Reading only: nothing here runs the page or changes a setting.
set -euo pipefail
fail() {
@@ -8,45 +25,174 @@ fail() {
}
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
page="$project_root/config/dot/quickshell/modules/settings/ApplicationsPage.qml"
settings="$project_root/config/dot/quickshell/modules/settings"
page="$settings/ApplicationsPage.qml"
row="$settings/InstalledAppRow.qml"
picker="$settings/AutostartAppPicker.qml"
types="$settings/FileTypePicker.qml"
qmldir="$settings/qmldir"
[[ -f "$page" ]] || fail 'Applications page is missing'
for path in "$page" "$row" "$picker" "$types" "$qmldir"; do
[[ -f "$path" ]] || fail "missing $path"
done
assert_contains() {
rg -F --quiet "$1" "$page" || fail "page is missing: $1"
}
assert_row_contains() {
rg -F --quiet "$1" "$row" || fail "the installed-application row is missing: $1"
}
assert_contains 'SettingsPage {'
assert_contains 'objectName: "applications"'
assert_contains 'DesktopEntries.applications.values'
assert_contains 'AppLibrary'
assert_contains 'DefaultApps'
assert_contains 'SettingsCard {'
assert_contains 'SettingRow {'
assert_contains 'activatable:'
assert_contains 'ActionRow {'
assert_contains 'TextRow {'
for label in Browser Mail Files Terminal Music Images Video; do
assert_contains "label: \"$label\""
# ── The cards, and what each one is for ──────────────────────────────────────
for title in 'Installed applications' 'Browse the catalog' 'Default applications' \
'Autostart' 'Search'; do
rg -F --quiet "title: \"$title\"" "$page" || fail "the page has no \"$title\" card"
done
assert_contains 'title: "Default applications"'
assert_contains 'title: "User autostart"'
assert_contains 'title: "Compositor autostart"'
# Every role still has a row, and the role list is still ten long: a family that
# quietly disappears takes its file types with it.
for label in Browser Mail Files Terminal Images Music Video Documents Text Archives; do
assert_contains "label: \"$label\""
done
[[ "$(rg --count 'key: "' "$page")" == "10" ]] \
|| fail 'the ten default-application roles are no longer ten'
# The roles are pickers now, not an accordion of buttons.
assert_contains 'OptionPickerRow {'
assert_contains 'onPicked: value => DefaultApps.setDefault('
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'currentEntry'
assert_contains 'choices.push(currentEntry)'
# The escape hatch for one type, and the service call behind it.
assert_contains 'label: "One file type"'
assert_contains 'FileTypePicker {'
assert_contains 'DefaultApps.searchTypes(query)'
assert_contains 'DefaultApps.setType(mime, desktopId)'
rg -Fq 'signal queried(string query)' "$types" \
|| fail 'the file-type picker cannot ask for a search'
rg -Fq 'signal chosen(string mime, string desktopId)' "$types" \
|| fail 'the file-type picker cannot report which application was chosen for a type'
# ── The installed list ───────────────────────────────────────────────────────
assert_contains 'InstalledAppRow {'
assert_contains 'AppLibrary.matches(app, installedSearch.text)'
assert_row_contains 'signal uninstallArmed'
assert_row_contains 'signal uninstallConfirmed'
assert_row_contains 'root.confirmingUninstall'
assert_row_contains 'label: "Permissions"'
assert_row_contains 'label: "Start with the session"'
assert_row_contains 'label: "Elsewhere in Settings"'
# Removing a system package is refused, by name, with the command that would do
# it. This is the whole reason the page can be trusted with an Uninstall button
# at all: it does one thing, and says plainly what it will not do.
assert_row_contains 'label: "Managed by dnf"'
assert_row_contains 'sudo dnf remove '
rg -v '^\s*//' "$page" | rg -q 'dnf remove' \
&& fail 'the page offers a package removal outside the honest refusal row'
python3 - "$page" "$row" <<'PY' || fail 'the page can run a package manager'
import re
import sys
for path in sys.argv[1:]:
text = open(path, encoding="utf-8").read()
# Every argument list handed to a process. A dnf or rpm in one of these is a
# settings page removing packages, whatever the button says.
for match in re.finditer(r"exec\w*\(\s*(\[[^\]]*\])", text):
argv = match.group(1)
if re.search(r'"(dnf|rpm|pkexec|sudo|yum|rpm-ostree)"', argv):
print(f"{path}: {argv.strip()[:120]}", file=sys.stderr)
raise SystemExit(1)
PY
# Flatseal is offered only when it is installed: a button that does nothing is
# worse than no button.
assert_contains 'flatsealAvailable'
assert_contains 'com.github.tchx84.Flatseal'
# The jump chips are shown where a rule exists, not everywhere.
assert_row_contains 'hasNotificationRule'
assert_row_contains 'hasSoundRule'
assert_contains 'Notifs.applications'
assert_contains 'AudioDevices.applications'
# ── The catalog ──────────────────────────────────────────────────────────────
#
# The id passed to install is the catalog line verbatim. Handing over the
# human-readable `ref` instead would be refused by the helper for every Flathub
# entry in the catalog -- the failure would look like "installing is broken".
assert_contains 'AppLibrary.install(root.activeCategory,'
rg -q 'AppLibrary\.install\([^)]*\.ref' "$page" \
&& fail 'the catalog installs by the display ref rather than by the catalog id'
assert_contains 'AppLibrary.entriesFor('
assert_contains 'system package, so installing asks for your password'
assert_contains 'value: catalogRow.installed ? "Installed" : ""'
# ── Autostart ────────────────────────────────────────────────────────────────
assert_contains 'AutostartAppPicker {'
assert_contains 'DefaultApps.addAutostart('
assert_contains 'label: "Add an application"'
assert_contains 'categories'
assert_contains 'genericName'
assert_contains '.sort('
assert_contains 'currentEntry'
assert_contains 'label: "Compositor autostart"'
assert_contains 'read-only'
assert_contains 'choices.push(currentEntry)'
assert_contains 'label: "Application settings need attention"'
assert_contains 'DefaultApps.busy ? "Loading…"'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.autostartEntries.length === 0'
assert_contains 'visible: !DefaultApps.busy && DefaultApps.luaAutostartEntries.length === 0'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
# The compositor's entries are described, never toggled: the file they live in
# is read once at launch, so a switch here would silently do nothing.
python3 - "$page" <<'PY' || fail 'a compositor autostart entry is offered as something to change'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for match in re.finditer(r"model: DefaultApps\.luaAutostartEntries", text):
tail = text[match.start():match.start() + 1200]
if "SettingsToggle" in tail or "setAutostart" in tail:
print(tail[:200], file=sys.stderr)
raise SystemExit(1)
PY
# ── The launcher chord is read, not remembered ───────────────────────────────
#
# "Super+Space" was hardcoded here through two rebinds of the launcher and told
# the wrong story both times.
assert_contains 'Keybinds.binds'
assert_contains 'root.launcherChords'
python3 - "$page" <<'PY' || fail 'the launcher chord is stated rather than read from the keymap'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
text = "\n".join("" if line.strip().startswith("//") else line
for line in source.splitlines())
# The literal may survive as the fallback shown while the keymap is still being
# read, but not as the value itself.
for match in re.finditer(r'"Super ?\+ ?Space"', text):
line = text[:match.start()].rsplit("\n", 1)[-1] + text[match.start():].split("\n", 1)[0]
if "launcherChords" not in line:
print(line.strip(), file=sys.stderr)
raise SystemExit(1)
PY
# ── The role matcher, run rather than read ───────────────────────────────────
#
# Extracted from the page and executed against fixtures. The interesting cases
# are the near misses: a media centre is not a music player, a document scanner
# is not an image viewer, and the categories arrive as a QML list rather than a
# JavaScript array -- which is the exact shape that once made Archives match
# nothing at all.
PAGE_PATH="$page" bun -e '
const source = await Bun.file(process.env.PAGE_PATH).text();
const rolesSource = source.match(/readonly property var roles:\s*(\[[\s\S]*?\n \])/);
@@ -58,7 +204,14 @@ if (!rolesSource || !matcherSource) {
const roles = Function(`return (${rolesSource[1]})`)();
const matchesRole = Function("entry", "role", matcherSource[1]);
const role = key => roles.find(candidate => candidate.key === key);
const role = key => {
const found = roles.find(candidate => candidate.key === key);
if (!found) {
console.error(`applications settings contract: no "${key}" role`);
process.exit(1);
}
return found;
};
const fixtures = [
{
name: "AudioVideo does not imply music",
@@ -101,6 +254,30 @@ const fixtures = [
entry: { name: "Loupe", genericName: "Image Viewer", comment: "Browse pictures", categories: "Graphics;Viewer;" },
role: "images",
expected: true
},
{
name: "an archive manager can be the archives handler",
entry: { name: "File Roller", genericName: "Archive Manager", comment: "Open archives", categories: "Utility;Archiving;" },
role: "archives",
expected: true
},
{
name: "categories that arrive as a list are read as categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: ["Utility", "Archiving"] },
role: "archives",
expected: true
},
{
name: "a comma-separated category string is still a list of categories",
entry: { name: "Ark", genericName: "Ark", comment: "", categories: "Utility,Archiving" },
role: "archives",
expected: true
},
{
name: "a text editor is not a terminal",
entry: { name: "Neovim", genericName: "Text Editor", comment: "Edit text", categories: "Utility;TextEditor;" },
role: "terminal",
expected: false
}
];
@@ -113,27 +290,29 @@ for (const fixture of fixtures) {
}
'
if rg --quiet 'Component\.onCompleted|DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page snapshots or performs a one-time desktop-entry lookup'
# ── House rules ──────────────────────────────────────────────────────────────
#
# The page may ask its services to load when it opens -- AppLibrary reads
# nothing until something wants it -- but it may not snapshot desktop entries or
# look one up by hand: both produce a list that stops tracking what is
# installed.
if rg --quiet 'DesktopEntries\.(byId|heuristicLookup)' "$page"; then
fail 'page performs a one-time desktop-entry lookup instead of tracking the live list'
fi
if rg -F --quiet 'label: "Could not apply the change"' "$page"; then
fail 'error heading incorrectly describes read failures as apply failures'
fi
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$page"; then
fail 'page introduces a color literal instead of the shared visual system'
fi
for path in "$page" "$row" "$types"; do
if rg --quiet '#[0-9A-Fa-f]{3,8}' "$path"; then
fail "$(basename "$path") introduces a color literal instead of the shared visual system"
fi
done
[[ "$(rg --count 'activatable:' "$page")" -ge 2 ]] \
|| fail 'default and autostart rows are not both whole-row activatable'
picker="$project_root/config/dot/quickshell/modules/settings/AutostartAppPicker.qml"
qmldir="$project_root/config/dot/quickshell/modules/settings/qmldir"
[[ -f "$picker" ]] || fail 'autostart application picker is missing'
rg -Fq 'required property var existing' "$picker" \
|| fail 'autostart picker cannot exclude existing entries'
rg -Fq 'signal picked(string id)' "$picker" \
|| fail 'autostart picker does not emit a validated desktop id'
rg -q '^AutostartAppPicker 1\.0 AutostartAppPicker\.qml$' "$qmldir" \
|| fail 'autostart picker is not registered in the Settings module'
# Every component the page draws is registered, or the page does not load at
# all -- and a QML page that fails to load looks like an empty tab.
for component in AutostartAppPicker InstalledAppRow FileTypePicker SettingsChip OptionPickerRow; do
rg -q "^$component 1\.0 $component\.qml$" "$qmldir" \
|| fail "$component is not registered in the Settings module"
done
printf 'applications settings contract: PASS\n'
+80
View File
@@ -313,4 +313,84 @@ rg --quiet 'image/png' "$call_log" \
rm -f "$config_home/mimeapps.list"
# ── One type on its own ──────────────────────────────────────────────────────
#
# The role rows set a whole family together, which is right almost always and
# wrong for the person whose .heic files should open somewhere other than the
# rest of their pictures. `set-type` is that escape hatch, and it is the one
# verb here that writes a type nobody curated -- so what it accepts is the
# question.
#
# Two guards, and they do different jobs: the type has to be one this system
# knows (a typo becomes a handler entry for a MIME type that will never exist),
# and the application has to be one that is installed (the same rule the role
# rows already keep).
assert_service_contains 'function searchTypes(query: string): void'
assert_service_contains 'function setType(mime: string, desktopId: string): void'
# The type database, as this fake machine has it.
mkdir -p "$data_home/mime"
cat >"$data_home/mime/globs2" <<'GLOBS'
# weight:type:glob
50:image/png:*.png
50:image/jpeg:*.jpg
50:image/heic:*.heic
50:text/markdown:*.md
50:application/pdf:*.pdf
GLOBS
# Two applications that declare what they open, so "candidates" has something
# true to report rather than passing on an empty list.
printf 'MimeType=image/png;image/jpeg;image/heic;\n' >>"$data_home/applications/org.gnome.Loupe.desktop"
printf 'MimeType=text/markdown;text/plain;\n' >>"$data_home/applications/panama-nvim.desktop"
: >"$call_log"
search="$($helper search-types heic)" || fail 'search-types failed'
jq -e '(.types | type == "array") and has("truncated")' <<<"$search" >/dev/null \
|| fail "search-types does not report a list of types and whether it was cut short: $search"
jq -e '[.types[] | has("mime") and has("handler") and has("candidates")] | all' <<<"$search" >/dev/null \
|| fail "a search result is missing its type, its current handler, or the applications that could open it: $search"
jq -e '[.types[].mime] | index("image/heic") != null' <<<"$search" >/dev/null \
|| fail "searching an extension did not find the type it belongs to: $search"
jq -e '[.types[] | select(.mime == "image/heic") | .candidates[].id]
| index("org.gnome.Loupe.desktop") != null' <<<"$search" >/dev/null \
|| fail "an application that declares the type is not offered for it: $search"
# Candidates are installed applications, never a name read out of a registry.
discovered="$(cd "$data_home/applications" && ls)"
while read -r candidate; do
[[ -n "$candidate" ]] || continue
rg -Fxq "$candidate" <<<"$discovered" \
|| fail "search-types offers '$candidate', which is not installed on this machine"
done < <(jq -r '[.types[].candidates[].id] | unique[]' <<<"$search")
# A one-character query is not a search: it would return the whole database.
jq -e '(.types | length) == 0' <<<"$($helper search-types a)" >/dev/null \
|| fail 'a single character was treated as a search of the whole type database'
: >"$call_log"
$helper set-type image/heic org.gnome.Loupe.desktop
assert_call $'default\norg.gnome.Loupe.desktop\nimage/heic'
# One type, and only that type: the whole point of the escape hatch is that it
# leaves the rest of the family where it was.
[[ "$(rg --count '^default$' "$call_log")" == "1" ]] \
|| fail 'setting one file type wrote more than one'
: >"$call_log"
if $helper set-type image/heic org.example.Missing.desktop >/dev/null 2>&1; then
fail 'set-type accepted an application that is not installed'
fi
if $helper set-type image/heic ../escape.desktop >/dev/null 2>&1; then
fail 'set-type accepted an unsafe desktop id'
fi
for bad_type in 'image' 'image/' '/png' 'image/png;rm -rf /' '' '../../etc/passwd' \
'image/does-not-exist'; do
if $helper set-type "$bad_type" org.gnome.Loupe.desktop >/dev/null 2>&1; then
fail "set-type accepted a type this system does not have: ${bad_type@Q}"
fi
done
[[ ! -s "$call_log" ]] || fail "a refused set-type still reached xdg-mime: $(cat "$call_log")"
printf 'default apps contract: PASS\n'
+368 -2
View File
@@ -37,6 +37,22 @@ 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" \
@@ -143,6 +159,356 @@ grep -Fxq 'unmount -b /dev/sdb1' "$PANAMA_DISKS_CALL_LOG" \
unset PANAMA_DISKS_LSBLK PANAMA_DISKS_CALL_LOG
printf 'disks contract: PASS (%d drives, %d filesystems)\n' \
# ── 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 '.filesystems | length' <<<"$snapshot")" \
"$(jq 'length' <<<"$cleanables")"
+189 -1
View File
@@ -107,6 +107,194 @@ grep -q 'keeping whatever is there now' "$page" \
grep -q 'Not measured' "$page" \
|| fail 'the page reports a per-snapshot size it cannot actually measure'
printf 'snapshots contract: PASS (%d volume(s), %d snapshot(s), no rollback)\n' \
# ── 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")"