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:
Executable
+539
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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")"
|
||||
|
||||
@@ -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")"
|
||||
|
||||
Reference in New Issue
Block a user