Finish the wonderland: System told truthfully, in eight tabs instead of ten

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 23:31:52 -04:00
parent 9ffaf45a4d
commit be0e55214b
57 changed files with 5040 additions and 925 deletions
+3 -1
View File
@@ -159,7 +159,9 @@ qml_package() {
# Provided by the base system or the shell itself; nothing installs these
# separately, and listing them would be noise.
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl)$'
# `qs` is Quickshell itself -- if the shell is running QML, qs is by
# definition present, so it needs no package list entry.
QML_BASELINE='^(sh|bash|rm|test|pkill|systemd-inhibit|loginctl|timedatectl|gsettings|gapplication|systemctl|busctl|qs)$'
while IFS= read -r command_name; do
[[ -n "$command_name" ]] || continue
@@ -38,7 +38,9 @@ SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|functio
# fingerprint aliases in config/bash can rely on it without declaring it.
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|ls|rfkill|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# bootctl ships in systemd-udev, which every Fedora install carries -- it is
# the udev half of systemd, not an optional tool.
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|bootctl|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
# Installed by install-packages itself rather than by a package list. Two
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
+31
View File
@@ -55,6 +55,21 @@ fail() {
# Fedora", explaining that GNOME's file-history switches would not take effect
# in a Hyprland session anyway -- is gone, because the switches it was
# apologizing for are now buttons that work.
# "color" joined with the same argument, one page over. System Health's Fedora
# card offered Color profiles because Panama's Displays page did not have them;
# it has had them per display for some time -- automatic, sRGB, wide gamut or
# HDR, with bit depth and SDR brightness beside them -- so the row was pointing
# at GNOME for something the desktop's own display page does better, since it
# is the page that knows which output you mean.
#
# "system region" joined when Region & Language stopped being a page that
# offered a language picker and a link, and its contents became the Language &
# formats card on Date, Time & Region: a language picker, four per-category
# format dropdowns backed by the installed locales, and a live preview of what
# each choice actually renders. GNOME's panel does the same job with the same
# locales, so a row pointing at it is now a door out of a page that does the
# thing. The retired page carried two: that one, and an "Open appearance"
# handoff left over from when the fonts lived here.
declare -A OWNED=(
[network]=connectivity
[wifi]=connectivity
@@ -64,6 +79,8 @@ declare -A OWNED=(
[users]=users
[system\ users]=users
[privacy]=privacy
[system\ region]=datetime
[color]=displays
)
# Handoffs that are correct despite naming an owned panel, with the reason.
@@ -127,6 +144,20 @@ if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then
violations=$((violations + 1))
fi
# The same, for the page that no longer exists. RegionPage's two handoffs went
# with the file, but the card they sat in moved to DateTimePage, and a card can
# be moved with its rows intact -- so this is asserted against the page that
# received the content rather than against the one that was deleted.
if [[ -e "$settings_dir/RegionPage.qml" ]]; then
printf 'gnome handoff contract: RegionPage.qml still exists; its route retired to datetime\n' >&2
violations=$((violations + 1))
fi
if grep -q 'openGnomePanel' "$settings_dir/DateTimePage.qml"; then
printf 'gnome handoff contract: DateTimePage opens a GNOME panel for something it now does itself:\n' >&2
grep -n 'openGnomePanel' "$settings_dir/DateTimePage.qml" >&2
violations=$((violations + 1))
fi
if (( violations > 0 )); then
printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2
exit 1
+161 -2
View File
@@ -23,6 +23,16 @@ confirm_snapshot="$(jq -c '
action: {kind: "repair", label: "Restart Panama", confirm: true}
}]
' <<<"$warning_snapshot")"
updates_snapshot="$(jq -c '
.checks[0].action = {kind: "open", label: "Open Software Update", confirm: false, target: "updates"}
' <<<"$warning_snapshot")"
# "storage" is a real Settings page that `settingsTargets` deliberately does
# not list. The allow-list has to be an allow-list: if any page id were taken
# on trust, extending the doctor's vocabulary would stop being a change that
# has to be made on both sides, and this whole coupling would be decorative.
unlisted_snapshot="$(jq -c '
.checks[0].action = {kind: "open", label: "Open Storage", confirm: false, target: "storage"}
' <<<"$warning_snapshot")"
projection_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret"
@@ -30,6 +40,11 @@ projection_snapshot="$(jq -c '
| .context.versions[0].fixtureSecret = "fixture-secret"
| .checks[0].fixtureSecret = "fixture-secret"
' <<<"$warning_snapshot")"
# One probe, in the same envelope as a full report. Re-checking a single row
# after fixing something by hand is the reason it exists: rescanning all thirty
# takes long enough that people stop doing it, and a row that never updates is
# a row that stops being believed.
single_check='{"schemaVersion":1,"generatedAt":"2026-08-24T00:00:00Z","summary":{"status":"healthy","healthy":1,"warnings":0,"errors":0,"unconfigured":0},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
adversarial_snapshot="$(jq -c '
.fixtureSecret = "fixture-secret"
| .summary.fixtureSecret = "fixture-secret"
@@ -85,6 +100,57 @@ if keys != ["summary", "busy", "generation", "acceptedGeneration", "checks"]:
raise SystemExit(1)
PY
# ── Both sides of a Settings target ─────────────────────────────────────────
#
# An `open` action carries the id of a Settings page, and the two halves of
# that agreement live in different languages in different directories: the
# doctor writes `target="updates"`, and Health decides whether it will accept
# one by looking the value up in `settingsTargets`.
#
# Getting one without the other is not a dead button. `validAction` returning
# false makes `validCheck` return false, which makes `consumeSnapshot` reject
# the WHOLE report -- so a single unlisted target takes every other check down
# with it and System Health goes blank, for a reason nothing on screen names.
# That is what "Open Software Update" would have done had it been given the
# target it was missing without `settingsTargets` being extended to match.
#
# Derived from both files rather than restated, so the next target added on
# either side has to be added on the other.
python3 - "$repo_dir/config/dot/quickshell/scripts/panama-doctor" "$service" <<'PY' \
|| fail 'the doctor emits a Settings or instructions target that Health would reject, which rejects the entire snapshot'
import re
import sys
doctor = open(sys.argv[1], encoding="utf-8").read()
health = open(sys.argv[2], encoding="utf-8").read()
def accepted(name: str) -> set[str]:
block = re.search(rf'property var {name}:\s*\[(.*?)\]', health, re.S)
if not block:
raise SystemExit(f"Health.qml no longer declares {name}")
return set(re.findall(r'"([a-z-]+)"', block.group(1)))
settings_targets = accepted("settingsTargets")
instruction_targets = accepted("instructionTargets")
emitted = re.findall(r'Action\(\s*"(open|instructions)"\s*,\s*"[^"]*"\s*,\s*target="([a-z-]+)"', doctor)
if not emitted:
raise SystemExit("no targeted actions were read from the doctor, so this proves nothing")
for kind, target in emitted:
allowed = settings_targets if kind == "open" else instruction_targets
if target not in allowed:
raise SystemExit(
f'the doctor emits a {kind} action targeting "{target}", which Health does not accept'
)
if "updates" not in settings_targets:
raise SystemExit('Health does not accept the "updates" target, so the Software Update '
'actions have nowhere to go')
PY
fixture_dir="$(mktemp -d /tmp/panama-health.XXXXXX)"
config_path="$fixture_dir/quickshell"
cp -a "$repo_dir/config/dot/quickshell" "$config_path"
@@ -130,6 +196,10 @@ printf '%s\n' \
" printf '%s\\n' '$warning_snapshot'" \
' exit 0' \
'fi' \
'if [[ "$1" == "check" ]]; then' \
' printf "%s\n" "$PANAMA_HEALTH_SINGLE_CHECK"' \
' exit 0' \
'fi' \
'if [[ "$1" == "--repair" ]]; then' \
' repair_start_time="$(awk '\''{ print $22 }'\'' "/proc/$$/stat")"' \
' printf "%s|%s\n" "$$" "$repair_start_time" >"$PANAMA_HEALTH_REPAIR_STARTED"' \
@@ -161,7 +231,7 @@ chmod +x "$copy_bin/wl-copy" "$copy_bin/notify-send"
run() {
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" "$@"
}
@@ -218,7 +288,7 @@ trap cleanup EXIT
PATH="$copy_bin:$PATH" PANAMA_HEALTH_HELPER="$helper" PANAMA_HEALTH_COPY_FILE="$copy_file" \
PANAMA_HEALTH_REPAIR_MODE_FILE="$repair_mode_file" PANAMA_HEALTH_REPAIR_LOG="$repair_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" \
PANAMA_HEALTH_NOTIFICATION_LOG="$notification_log" PANAMA_HEALTH_SINGLE_CHECK="$single_check" \
PANAMA_HEALTH_REPAIR_STARTED="$repair_started_file" PANAMA_HEALTH_REPAIR_RELEASE="$repair_release_file" \
qs -p "$harness" --daemonize >/dev/null
for _ in $(seq 1 40); do
@@ -237,6 +307,38 @@ state="$(run ipc call health-test status)"
jq -e '.status == "warning" and .acceptedGeneration == 0 and .checks == ["integration.calendar", "panama.caffeine"] and .diagnosticUnavailable == false' \
>/dev/null <<<"$state" || fail "valid warning snapshot was not accepted intact: $state"
[[ "$(run ipc call health-test accept "$updates_snapshot" 0)" == "true" ]] \
|| fail 'a check pointing at Software Update was rejected, so the whole report would go blank rather than one button being dead'
state="$(run ipc call health-test status)"
jq -e '.status == "warning" and .diagnosticUnavailable == false
and .checks == ["integration.calendar", "panama.caffeine"]' \
>/dev/null <<<"$state" || fail "the Software Update target did not survive acceptance intact: $state"
[[ "$(run ipc call health-test accept "$unlisted_snapshot" 0)" == "false" ]] \
|| fail 'a Settings target the service does not list was accepted, so the allow-list is not one'
# ── The command a repair will run, carried through ──────────────────────────
#
# Every field of a check is projected onto a known shape on the way in, which
# is what stops an unknown key from reaching the report -- and which means a
# NEW known key has to be added to the projection or it is silently dropped.
# `repairCommand` is the one where that failure is invisible: the repair still
# works, the row still says "Restart Vicinae", and the only thing missing is
# the sentence telling somebody what is about to run as them.
repair_command_snapshot="$(jq -c '
.checks[1].repairCommand = "systemd-inhibit --list"
' <<<"$warning_snapshot")"
[[ "$(run ipc call health-test accept "$repair_command_snapshot" 0)" == "true" ]] \
|| fail 'a check carrying its repair command was rejected'
jq -e '[.checks[] | select(.id == "panama.caffeine") | .repairCommand] == ["systemd-inhibit --list"]' \
>/dev/null <<<"$(run ipc call health-test report)" \
|| fail 'the repair command was projected away, so the row cannot say what it is about to run'
# A check with no repair, or a doctor that has not learned to send one, is
# still a check. Dropping the field is fine; refusing the report is not.
[[ "$(run ipc call health-test accept "$warning_snapshot" 0)" == "true" ]] \
|| fail 'a check without a repair command was rejected once the field existed'
[[ "$(run ipc call health-test accept "$projection_snapshot" 0)" == "true" ]] \
|| fail 'snapshot with unknown non-action fields was rejected instead of safely projected'
stored_report="$(run ipc call health-test report)"
@@ -398,6 +500,63 @@ state="$(run ipc call health-test status)"
jq -e '.repairingId == "" and .generation == ($before + 1)' --argjson before "$confirm_generation" \
>/dev/null <<<"$state" || fail "rejected repair altered process state: $state"
# ── One row, re-checked ─────────────────────────────────────────────────────
#
# The whole scan is thirty probes with network and D-Bus work behind several of
# them. Somebody who has just restarted a service by hand wants to know about
# that service, and making them wait nine seconds for the other twenty-nine is
# how a Re-check button stops being pressed and a stale row stops being
# believed. So `refreshCheck` runs one probe -- and has to leave the rest of
# the accepted report exactly as it was, since a single-check response says
# nothing about any other row.
: >"$repair_log"
[[ "$(run ipc call health-test recheck panama.caffeine)" == "true" ]] \
|| fail 'a single-check refresh was refused for a check that is in the report'
for _ in $(seq 1 60); do
state="$(run ipc call health-test status)"
jq -e '(.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"' \
>/dev/null <<<"$state" && break
sleep 0.1
done
jq -e '.checks == ["integration.calendar", "panama.caffeine"]
and (.checkStates[] | select(.id == "panama.caffeine") | .status) == "ok"
and (.checkStates[] | select(.id == "integration.calendar") | .status) == "warning"
and .summary == {status: "warning", healthy: 1, warnings: 1, errors: 0, unconfigured: 0}' \
>/dev/null <<<"$state" \
|| fail "a single-check refresh did not update exactly the one row it probed: $state"
grep -Fxq 'check panama.caffeine' "$repair_log" \
|| fail "refreshCheck did not ask the doctor for one check: $(<"$repair_log")"
! grep -Fxq -- '--json' "$repair_log" \
|| fail 'a single-check refresh ran the whole thirty-probe scan anyway'
: >"$repair_log"
[[ "$(run ipc call health-test recheck unknown.check)" == "false" ]] \
|| fail 'a check id that is not in the report started a probe'
[[ ! -s "$repair_log" ]] || fail 'a refused single-check refresh started a process'
# ── The report, saved rather than copied ────────────────────────────────────
#
# Copy Report puts the diagnostics on the clipboard, which is the right answer
# when the next step is pasting it into a message and the wrong one when the
# next step is attaching it, or reading it in an editor, or sending it from a
# session that is the thing being diagnosed. Same redacted projection, written
# to a file.
report_file="$fixture_dir/health-report.txt"
[[ "$(run ipc call health-test save "$report_file")" == "true" ]] \
|| fail 'saving the health report was refused'
for _ in $(seq 1 40); do
[[ -s "$report_file" ]] && break
sleep 0.1
done
[[ -s "$report_file" ]] || fail 'the saved health report is absent or empty'
grep -Fq 'panama.caffeine' "$report_file" \
|| fail 'the saved report does not contain the checks it is a report of'
! grep -Fq 'fixture-secret' "$report_file" \
|| fail 'the saved report is not the redacted projection that copyReport writes'
rg -Fq 'panama-health-report.txt' "$service" \
|| fail 'saveReport has no default destination, so the row has nowhere to write without a file dialog'
python3 - "$service" <<'PY' || fail 'external repair failure notification is not bounded'
import sys
+105 -8
View File
@@ -78,10 +78,46 @@ rg -Fq 'SystemSettings.openGnomePanel("system", "users")' "$settings_dir/HealthP
&& fail 'System Health still hands Users to GNOME, but Panama owns that page'
rg -Fq 'SystemSettings.openGnomePanel("sharing")' "$settings_dir/HealthPage.qml" \
&& fail 'System Health still hands Sharing to GNOME, but Panama owns that page'
# Colour profiles left the card for the same reason Users and Sharing did:
# Displays offers a colour profile, a bit depth, an SDR brightness and an SDR
# saturation per output, which is more than GNOME's panel can say in a session
# it does not manage. Digital wellbeing stays, because nothing here does it.
rg -Fq 'SystemSettings.openGnomePanel("color")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Color profiles handoff'
&& fail 'System Health still hands Colour profiles to GNOME, but Displays owns them per output'
rg -Fq 'SystemSettings.openGnomePanel("wellbeing")' "$settings_dir/HealthPage.qml" \
|| fail 'Fedora ownership boundary lost the Digital wellbeing handoff'
# ── A repair says what it will run, before it runs it ───────────────────────
#
# "Restart Vicinae" and "Release duplicate inhibitors" are what the buttons
# say. What they do is run a command as the person pressing them, and the row
# never said which. Somebody who wants to know what a button is about to do to
# their machine should not have to read panama-doctor to find out -- and after
# the fact is not the same answer, because by then the decision is made.
rg -Fq 'repairCommand' "$settings_dir/HealthPage.qml" \
|| fail 'a repair row never shows the command it is about to run'
python3 - "$settings_dir/HealthPage.qml" <<'PY' || fail 'the repair command is only shown once the repair has run, which is after the moment it was worth knowing'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
for line in text.splitlines():
if "repairCommand" not in line:
continue
# Gating the command on a repair result, a repairing id, or a "working"
# state makes it after-the-fact reassurance rather than a decision aid.
if re.search(r'\b(lastRepair|repairingId|repairing|working|repairFailed)\b', line):
raise SystemExit(1)
raise SystemExit(0)
PY
# ── One row, re-checked; and the report saved rather than copied ────────────
rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthCheckRow.qml" \
|| rg -Fq 'Health.refreshCheck(' "$settings_dir/HealthPage.qml" \
|| fail 'no row offers a re-check, so a row fixed by hand stays wrong until the whole scan runs again'
rg -Fq 'Health.saveReport(' "$settings_dir/HealthPage.qml" \
|| rg -Fq 'Health.saveReport(' "$settings_dir/HealthSummary.qml" \
|| fail 'the report can only be copied, never saved'
# Exact authored handoffs are asserted above. Also prove every panel named by
# this boundary is accepted by SystemSettings, so a typo cannot ship a dead
# button even if its copy still looks correct.
@@ -122,7 +158,9 @@ labels = (
'if (status === "error") return "Action required";',
'return "Not set up";',
)
assert all(label in page for label in labels)
# The four status words belong to the row that shows them. They used to be
# written on the page, one indirection away from the Text that rendered them.
assert all(label in row for label in labels)
assert 'group: "desktop-foundation"' in page
assert 'group: "input-media"' in page
assert 'group: "integrations"' in page
@@ -140,7 +178,7 @@ assert 'pendingConfirmation' in page
assert 'ddc-permissions' in page
PY
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":2,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false}},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true}},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."}]}'
fixture='{"schemaVersion":1,"generatedAt":"2026-08-18T12:00:00Z","summary":{"status":"error","healthy":2,"warnings":3,"errors":1,"unconfigured":1},"context":{"session":"hyprland","versions":[{"id":"quickshell","version":"0.3.0"}]},"checks":[{"id":"desktop.vicinae","group":"desktop-foundation","title":"Vicinae","status":"warning","detail":"The launcher service is stopped.","action":{"kind":"repair","label":"Restart Vicinae","confirm":false},"repairCommand":"systemctl --user restart vicinae.service"},{"id":"desktop.quickshell","group":"desktop-foundation","title":"Quickshell","status":"error","detail":"Panama shell needs to restart.","action":{"kind":"repair","label":"Restart Panama","confirm":true},"repairCommand":"panama-action restart-shell"},{"id":"input.pipewire","group":"input-media","title":"PipeWire","status":"ok","detail":"Audio graph is responding."},{"id":"integration.bluebubbles","group":"integrations","title":"BlueBubbles","status":"unconfigured","detail":"Messaging integration has not been enabled."},{"id":"integration.calendar","group":"integrations","title":"Calendar","status":"warning","detail":"Calendar probe timed out.","action":{"kind":"open","label":"Open Date & Time","confirm":false,"target":"datetime"}},{"id":"panama.caffeine","group":"panama-tools","title":"Caffeine","status":"ok","detail":"No duplicate sleep inhibitors."},{"id":"panama.updates","group":"panama-tools","title":"Software updates","status":"warning","detail":"3 pending updates carry a security advisory.","action":{"kind":"open","label":"Open Software Update","confirm":false,"target":"updates"}}]}'
state_home="$(mktemp -d /tmp/panama-health-ui.XXXXXX)"
config_path="$state_home/quickshell"
@@ -251,6 +289,39 @@ ShellRoot {
});
}
// The Software Update action, end to end: the doctor authors the
// target, Health accepts it, and the row has to actually go there.
function offerUpdatesAction(): bool {
ShellState.settingsPage = "services";
return Health.consumeSnapshot(JSON.stringify({
schemaVersion: 1,
generatedAt: "2026-08-24T12:00:00Z",
summary: { status: "warning", healthy: 0, warnings: 1, errors: 0, unconfigured: 0 },
context: { session: "hyprland", versions: [] },
checks: [{
id: "panama.updates",
group: "panama-tools",
title: "Software updates",
status: "warning",
detail: "3 pending updates carry a security advisory.",
action: {
kind: "open",
label: "Open Software Update",
confirm: false,
target: "updates"
}
}]
}), Health.acceptedGeneration + 1);
}
function followUpdatesAction(): string {
const requested = settingsShell.requestHealthAction("panama.updates");
return JSON.stringify({
requested: requested,
page: ShellState.settingsPage
});
}
function activateIndicator(): string {
ShellState.settingsPage = "home";
ShellState.settingsOpen = false;
@@ -311,19 +382,19 @@ jq -e '
{objectName:"health-check-row:issue:desktop.vicinae", id:"desktop.vicinae", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:issue:desktop.quickshell", id:"desktop.quickshell", section:"issue", statusText:"Action required"},
{objectName:"health-check-row:issue:integration.calendar", id:"integration.calendar", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:issue:panama.updates", id:"panama.updates", section:"issue", statusText:"Needs attention"},
{objectName:"health-check-row:quiet:input.pipewire", id:"input.pipewire", section:"quiet", statusText:"Healthy"},
{objectName:"health-check-row:quiet:integration.bluebubbles", id:"integration.bluebubbles", section:"quiet", statusText:"Not set up"},
{objectName:"health-check-row:quiet:panama.caffeine", id:"panama.caffeine", section:"quiet", statusText:"Healthy"}
]
and (.renderedRows | map(.id) | length) == 6
and (.renderedRows | map(.id) | unique | length) == 6
and (.renderedRows | map(.id) | length) == 7
and (.renderedRows | map(.id) | unique | length) == 7
and .emptyQuietGroups == ["desktop-foundation"]
and .fedoraHandoffs == [
{id:"color", label:"Color profiles", action:"Open color"},
{id:"wellbeing", label:"Digital wellbeing", action:"Open wellbeing"}
]
and .summaryHeight == 126
and (.rowHeights | length) == 6
and (.rowHeights | length) == 7
and (.rowHeights | all(. >= 62))
and .checking == true
and .checkingText == "Checking…"
@@ -363,7 +434,7 @@ jq -e '.renderedRows[] | select(.id == "desktop.vicinae") | .statusText == "Repa
>/dev/null <<<"$failed_repair_state" || fail "repair failure was not shown inline: $failed_repair_state"
[[ "$(jq -c .rowHeights <<<"$failed_repair_state")" == "$settled_heights" ]] \
|| fail 'repair failure changed row geometry'
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 6 ]] \
[[ "$(jq -r '.renderedRows | map(.id) | unique | length' <<<"$failed_repair_state")" == 7 ]] \
|| fail 'repair state duplicated a health action row'
for _ in $(seq 1 40); do
failed_repair_state="$(run ipc call health-ui-test state)"
@@ -432,6 +503,32 @@ jq -e '
and .refreshRequested == true
' >/dev/null <<<"$activation_state" || fail "indicator activation did not open and refresh System Health: $activation_state"
# ── The Software Update action goes to Software Update ──────────────────────
#
# The two health checks that offered "Open Software Update" carried no target
# at all, so the button rendered, focused, and did nothing -- the failure this
# whole target mechanism exists to make impossible, sitting inside it. Fixing
# it needed a change on both sides at once (panama-doctor authors the target,
# Health.settingsTargets accepts it), and each half is pinned where it lives.
# This is the third piece: the row acts on it.
[[ "$(run ipc call health-ui-test offerUpdatesAction)" == "true" ]] \
|| fail 'a check offering the Software Update action was rejected by the service'
# Both conditions: the row rendered, and the scan the page starts when it loads
# has settled. A row action is disabled while a check is running, which is
# correct and would otherwise read here as a dead button.
for _ in $(seq 1 60); do
updates_state="$(run ipc call health-ui-test state)"
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
>/dev/null <<<"$updates_state" && break
sleep 0.1
done
jq -e '.checking == false and (.renderedRows | any(.id == "panama.updates"))' \
>/dev/null <<<"$updates_state" \
|| fail "the Software Update check did not render on a settled page: $updates_state"
follow_state="$(run ipc call health-ui-test followUpdatesAction)"
jq -e '.requested == true and .page == "updates"' >/dev/null <<<"$follow_state" \
|| fail "the Software Update action did not open Software Update: $follow_state"
if rg -i 'QQml|ReferenceError|TypeError|binding loop|failed to load component' "$shell_log"; then
fail 'isolated fixture emitted QML errors or warnings'
fi
+152 -13
View File
@@ -24,6 +24,9 @@ set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
manual_dir="$repo_dir/config/dot/quickshell/manual"
page="$repo_dir/config/dot/quickshell/modules/settings/ManualPage.qml"
# The chapter list and the titles live in one place that both the reader and
# About's Manual card instantiate, so neither can drift from the other.
contents="$repo_dir/config/dot/quickshell/modules/settings/ManualChapters.qml"
routes="$repo_dir/config/dot/quickshell/services/SettingsRoutes.qml"
shell_ui="$repo_dir/config/dot/quickshell/modules/settings/SettingsShell.qml"
qmldir="$repo_dir/config/dot/quickshell/modules/settings/qmldir"
@@ -40,16 +43,18 @@ note() { findings+=("$1"); }
mapfile -t on_disk < <(find "$manual_dir" -maxdepth 1 -name '*.md' -printf '%f\n' | sort)
(( ${#on_disk[@]} > 0 )) || note 'the manual has no chapters'
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$page" | sed 's/file: "//; s/"//' | sort)
(( ${#listed[@]} > 0 )) || note 'the manual page lists no chapters'
[[ -r "$contents" ]] \
|| note 'ManualChapters.qml is missing, so neither the reader nor About knows what the chapters are'
mapfile -t listed < <(grep -oE 'file: "[^"]+\.md"' "$contents" | sed 's/file: "//; s/"//' | sort)
(( ${#listed[@]} > 0 )) || note 'the chapter list names no chapters'
for file in "${on_disk[@]}"; do
printf '%s\n' "${listed[@]}" | grep -qx "$file" \
|| note "$file exists but the manual page never shows it"
|| note "$file exists but the chapter list never names it"
done
for file in "${listed[@]}"; do
[[ -r "$manual_dir/$file" ]] \
|| note "the manual page lists $file, which does not exist, so that chapter renders an error"
|| note "the chapter list names $file, which does not exist, so that chapter renders an error"
done
# A chapter that is only a heading is a chapter somebody forgot to write.
@@ -60,6 +65,43 @@ for file in "${on_disk[@]}"; do
|| note "$file does not begin with a heading, so it has no title of its own"
done
# ── 1b. The tab titles come from the files ───────────────────────────────────
#
# The page carried a comment saying the title shown is the first heading of
# each chapter, "so a chapter cannot be renamed in one place and not the
# other". It was not true: the titles were a second copy, written beside the
# filenames, and the two agreed only because nobody had renamed anything yet.
# A comment that describes a property the code does not have is worse than no
# comment, because it is the reason the next person does not check.
#
# So: no authored label beside a chapter file, and something that actually
# reads a leading heading out of one.
python3 - "$contents" <<'PY' || note 'the chapter list still carries hand-written titles beside the filenames, so a renamed heading and a tab label can disagree'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'chapters:\s*\[(.*?)\n \]', text, re.S)
if not block:
raise SystemExit(1)
raise SystemExit(1 if re.search(r'label:\s*"', block.group(1)) else 0)
PY
grep -qE '\^#' "$contents" \
|| note 'nothing reads a leading heading, so the chapter titles cannot be coming from the files'
grep -q 'Quickshell.shellDir + "/manual/"' "$contents" \
|| note 'the chapter titles are not read through the shell directory, so they would break on a clone elsewhere'
grep -q 'ManualChapters {' "$page" \
|| note 'the manual reader keeps its own idea of what the chapters are'
# And the titles the files offer have to be usable as tab labels: a first line
# that is a paragraph would render a tab strip nobody can read.
for file in "${on_disk[@]}"; do
heading="$(head -1 "$manual_dir/$file" | sed 's/^#\+[[:space:]]*//')"
[[ -n "$heading" ]] || note "$file has an empty first heading, so its tab would have no name"
(( ${#heading} <= 40 )) \
|| note "$file's first heading is ${#heading} characters; it is the tab label, so it has to be a title"
done
# ── 2 & 3. How it renders ────────────────────────────────────────────────────
grep -q 'textFormat: Text.MarkdownText' "$page" \
@@ -68,6 +110,75 @@ grep -q 'root.chapters\[root.current\]' "$page" \
|| note 'the page does not render one chapter at a time; a single long Text goes blank rather than erroring'
grep -q 'onLinkActivated' "$page" \
|| note 'links in the manual do nothing when clicked'
# ── 3b. A link to a settings page opens the settings page ────────────────────
#
# The whole reason the manual is rendered inside Settings rather than in a
# browser is that a chapter can point at a page and have that mean something.
# Every link went to Qt.openUrlExternally, so "open Displays" handed a
# panama:// URL to xdg-open, which has no handler for it: the click did
# nothing, silently, which is the worst of the three possible outcomes.
grep -q 'panama://settings/' "$page" \
|| note 'the manual has no in-app link scheme, so a chapter cannot point at a settings page'
python3 - "$page" <<'PY' || note 'the manual link handler does not route in-app links through ShellState.openSettings while still sending everything else out of the desktop'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
def block_at(index: int) -> str:
"""The braced body starting at the first { on or after index."""
start = text.index("{", index)
depth = 0
for position in range(start, len(text)):
if text[position] == "{":
depth += 1
elif text[position] == "}":
depth -= 1
if depth == 0:
return text[start:position + 1]
raise SystemExit(1)
# Either shape is fine: the branch written inline, or delegated to a named
# function on the page. What matters is what is on the path, so the path is
# followed rather than one line being read and guessed at.
handler = re.search(r'onLinkActivated:\s*link\s*=>\s*(?P<tail>.)', text)
if not handler:
raise SystemExit(1)
if handler.group("tail") == "{":
body = block_at(handler.start("tail"))
else:
called = re.match(r'(?:root\.)?([A-Za-z][A-Za-z0-9]*)\(', text[handler.start("tail"):])
if not called:
raise SystemExit(1)
declaration = re.search(rf'function {called.group(1)}\s*\(', text)
if not declaration:
raise SystemExit(1)
body = block_at(declaration.end())
raise SystemExit(0 if "ShellState.openSettings" in body
and "Qt.openUrlExternally" in body
and ("panama://settings/" in body or "linkScheme" in body) else 1)
PY
# Every in-app link a chapter actually writes must name a page that exists.
# A typo here is invisible: the scheme matches, the handler fires, and
# openSettings resolves an unknown id to Home -- so the link works, just not
# the way the sentence around it promised.
leaves="$( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)"
[[ -n "$leaves" ]] || note 'no leaves could be read from SettingsRoutes, so in-app manual links prove nothing'
while read -r target; do
[[ -n "$target" ]] || continue
grep -qx "$target" <<<"$leaves" \
|| note "a chapter links to panama://settings/$target, which is not a page anyone can land on"
done < <(grep -rhoE 'panama://settings/[a-z-]+' "$manual_dir" \
| sed -E 's|panama://settings/||' | sort -u)
grep -q 'onLoadFailed' "$page" \
|| note 'a chapter that cannot be read fails silently instead of saying so'
@@ -80,18 +191,31 @@ grep -q '\.\./\.\./\.\.' "$page" \
&& note 'the manual path walks upward out of the shell directory, which is only correct by accident'
# ── 4. Registered everywhere a settings page has to be ───────────────────────
# The manual is a tab of the System category rather than a sidebar row of its
# own -- it is reference material, not a control surface, and it belongs beside
# About for the same reason. SettingsRoutes is what makes it reachable at all:
# a leaf missing from the taxonomy cannot be opened, searched, or linked to.
# The manual is a routable leaf with no tab of its own. It was a tab of System
# until the strip reached ten and had to come back down to eight, and it is the
# right one to lose: it is reference material rather than a control surface, so
# it is opened deliberately -- from About's Manual card, from a deep link, from
# a search result -- rather than found by scanning a row of tabs.
#
# That makes the taxonomy entry the only thing holding it up. A leaf missing
# from SettingsRoutes cannot be opened, searched, or linked to, and because it
# draws no tab anywhere, nothing on screen would show it had gone.
grep -q '{ page: "manual", label: ' "$routes" || note 'the manual is not a leaf in SettingsRoutes, so nothing can navigate to it'
python3 - "$routes" <<'PY' || note 'the manual is no longer a tab of the System category, so it has drifted out of the group it belongs to'
import re, sys
python3 - "$routes" <<'PY' || note 'the manual is not a hidden leaf in SettingsRoutes, so nothing can navigate to it'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\] \}', text, re.S)
raise SystemExit(0 if block and '{ page: "manual"' in block.group(1) else 1)
block = re.search(r'hiddenLeaves:\s*\[(.*?)\]', text, re.S)
raise SystemExit(0 if block and re.search(r'page: "manual"', block.group(1)) else 1)
PY
python3 - "$routes" <<'PY' || note 'the manual is a System tab again, taking back a slot the eight-tab strip does not have'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
raise SystemExit(1 if block and '{ page: "manual"' in block.group(1) else 0)
PY
grep -q 'case "manual": return manualPage;' "$shell_ui" || note 'SettingsShell does not route to the manual'
grep -q 'Component { id: manualPage; ManualPage {} }' "$shell_ui" || note 'SettingsShell never declares the manual component'
@@ -102,6 +226,21 @@ grep -q '^ManualPage 1.0 ManualPage.qml$' "$qmldir" || note 'ManualPage is not r
grep -q 'SettingsRoutes.resolve(' "$state" || note 'ShellState does not resolve pages through SettingsRoutes, so openSettings("manual") has no defined destination'
grep -q 'page: "manual"' "$search" || note 'the manual is not searchable from the settings search box'
# With no tab of its own, About's Manual card is the only place the manual is
# offered rather than looked up. If that card stops opening it, the page is
# still reachable in principle and undiscoverable in practice.
about="$repo_dir/config/dot/quickshell/modules/settings/AboutPage.qml"
if [[ -r "$about" ]]; then
grep -q 'openSettings("manual"' "$about" \
|| note 'About does not open the manual, which is now the only place it is offered rather than searched for'
grep -q 'ManualChapters {' "$about" \
|| note 'About keeps its own idea of what the chapters are, so its card and the reader can disagree'
grep -qE 'file: "[^"]+\.md"' "$about" \
&& note 'About names chapter files itself instead of reading the shared list'
else
note 'AboutPage.qml is missing, so the manual has nowhere to be opened from'
fi
if (( ${#findings[@]} > 0 )); then
printf 'manual contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
+7 -1
View File
@@ -53,10 +53,16 @@ while read -r page; do
[[ -n "$page" ]] || continue
[[ "$page" == "home" ]] && continue
expected[settings-$page]="settings-page $page"
#
# Hidden leaves count too. The manual has no tab of its own -- it is reference
# material rather than a control surface -- but "manual" is exactly the sort of
# thing somebody types into a launcher, and a leaf with no tab is the one leaf
# with nowhere else to be found by scanning.
done < <( {
grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs: \[\] \}' "$routes"
grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes"
} | sed -E 's/\{ page: "([a-z-]+)".*/\1/' | sort -u)
sed -n '/property var hiddenLeaves:/,/\]/p' "$routes" | grep -oE 'page: "[a-z-]+"'
} | sed -E 's/.*page: "([a-z-]+)".*/\1/' | sort -u)
(( ${#expected[@]} > 18 )) || fail 'no generated per-page commands were found; run scripts/panama-settings-commands'
+180 -1
View File
@@ -33,8 +33,13 @@ state_home="$home/.local/state"
runtime_dir="$fixture/runtime"
bin_dir="$fixture/bin"
data_home="$home/.local/share"
# The updates check reads the Updates page's cache out of XDG_CACHE_HOME rather
# than through DoctorConfig, so without this the fixture would read the real
# machine's update state and report whatever happened to be pending today.
cache_home="$home/.cache"
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts"
mkdir -p "$config_home" "$state_home" "$runtime_dir" "$bin_dir" "$data_home/vicinae/scripts" \
"$cache_home/panama"
# A mount table with the document portal present, which is the healthy state the
# rest of this file assumes. Written rather than read from /proc so the contract
@@ -114,6 +119,7 @@ run_doctor() {
HOME="$home" \
PATH="$bin_dir" \
XDG_CURRENT_DESKTOP=Hyprland \
XDG_CACHE_HOME="$cache_home" \
PANAMA_HOME_ASSISTANT_URL='https://fixture.invalid' \
PANAMA_HOME_ASSISTANT_TOKEN='fixture-secret-token' \
PANAMA_DOCTOR_ROOT="$repo_dir" \
@@ -803,4 +809,177 @@ for rejected_id in unknown.check integration.home-assistant input.brightness \
[[ "$(fixture_state)" == "$before_state" ]] || fail "$rejected_id mutated the filesystem"
done
# ── Every check in the order has a title ────────────────────────────────────
#
# `unavailable_check` is the doctor's own containment: a probe that raises
# becomes a warning row rather than a crashed report. It reads the title out of
# CHECK_TITLES by key, and `panama.updates` was never added there -- so the one
# path that exists to keep a failing probe from taking the report down was
# itself a KeyError, raised inside the `except` handler that was catching the
# original failure, from where it escaped `collect_checks`, hit `snapshot`'s
# outer guard, and raised again building the all-unavailable fallback.
#
# Nobody saw it, because it needs the updates probe to fail, and the updates
# probe reads a cache file and almost never does. Both halves are asserted:
# the invariant, which is cheap and total, and the path, which is the one that
# actually ran.
/usr/bin/python3 - "$doctor" <<'PY' || fail 'a check in CHECK_ORDER has no title, so a failing probe raises KeyError from inside the handler that exists to contain it'
import importlib.machinery
import importlib.util
import sys
loader = importlib.machinery.SourceFileLoader("panama_doctor_titles", sys.argv[1])
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
missing = [check for check in module.CHECK_ORDER if check not in module.CHECK_TITLES]
if missing:
raise SystemExit(f"no title for {missing}")
for check in module.CHECK_ORDER:
module.unavailable_check(check)
PY
/usr/bin/python3 - "$doctor" "$fixture/unavailable-home" <<'PY' || fail 'a probe that raises does not become a warning row; it takes the whole report with it'
import importlib.machinery
import importlib.util
import sys
from pathlib import Path
doctor_path, home_text = sys.argv[1:]
loader = importlib.machinery.SourceFileLoader("panama_doctor_unavailable", doctor_path)
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
home = Path(home_text)
home.mkdir(parents=True)
config = module.DoctorConfig(home, home, home / "config", home / "state", home / "runtime", "", 0.05)
def raising(_config):
raise TimeoutError("fixture probe timeout")
module.check_updates = raising
report = module.snapshot(config)
ids = [check["id"] for check in report["checks"]]
if ids != list(module.CHECK_ORDER):
raise SystemExit("a raising probe changed the shape of the report")
row = next(check for check in report["checks"] if check["id"] == "panama.updates")
if row["status"] != "warning" or not row["title"]:
raise SystemExit(f"the contained row is not a titled warning: {row}")
PY
# ── The updates check knows where Software Update is ────────────────────────
#
# Two of its four states offered "Open Software Update" with no target, so the
# button rendered and did nothing. Adding the target is only half the change --
# Health.settingsTargets has to accept it, or the whole report is rejected --
# and health-service-contract pins the other half against this same file.
/usr/bin/python3 - "$cache_home/panama/updates.json" <<'PY'
import json
import sys
import time
json.dump({
"checkedAt": int(time.time()),
"dnf": {"count": 5, "securityCount": 3},
"flatpak": {"count": 0},
"firmware": {"count": 0},
}, open(sys.argv[1], "w"))
PY
pending_updates="$(run_doctor --json)"
check_status "$pending_updates" panama.updates warning
jq -e '.checks[] | select(.id == "panama.updates")
| .action == {kind:"open", label:"Open Software Update", confirm:false, target:"updates"}' \
>/dev/null <<<"$pending_updates" || fail 'the updates action carries no Settings target, so the button does nothing'
assert_schema_and_redaction "$pending_updates"
# ── A repair row says what it will run ──────────────────────────────────────
#
# The command is the literal from REPAIR_COMMANDS and nothing else: derived
# from the table rather than restated here, so a repair whose argv changes
# cannot leave the row describing the old one, and probe output can never reach
# this field.
repairable="$(PANAMA_DOCTOR_MOUNTINFO="$fixture/mountinfo-unmounted" run_doctor --json)"
check_status "$repairable" desktop.document-portal warning
printf '%s' "$repairable" >"$fixture/repairable-report.json"
/usr/bin/python3 - "$doctor" "$fixture/repairable-report.json" <<'PY' || fail 'a repairable check does not carry the exact authored repair command'
import importlib.machinery
import importlib.util
import json
import sys
loader = importlib.machinery.SourceFileLoader("panama_doctor_repair_command", sys.argv[1])
spec = importlib.util.spec_from_loader(loader.name, loader)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
loader.exec_module(module)
report = json.load(open(sys.argv[2], encoding="utf-8"))
expected = {check: " ".join(command) for check, command in module.REPAIR_COMMANDS.items()}
seen = 0
for check in report["checks"]:
rendered = check.get("repairCommand")
if check["id"] in expected and check.get("action", {}).get("kind") == "repair":
if rendered != expected[check["id"]]:
raise SystemExit(f'{check["id"]} shows {rendered!r}, expected {expected[check["id"]]!r}')
seen += 1
elif rendered is not None and rendered != expected.get(check["id"]):
raise SystemExit(f'{check["id"]} shows a command that is not its authored one: {rendered!r}')
if not seen:
raise SystemExit("no repairable check carried a command, so this proves nothing")
PY
# ── One check, asked for on its own ─────────────────────────────────────────
#
# The whole scan is thirty probes. Re-checking the one row somebody just fixed
# should not wait on the other twenty-nine, so there is a verb that runs one --
# and it answers in the same envelope as a full report, because the consumer is
# the same parser and a second response shape is a second thing to get wrong.
single="$(run_doctor check desktop.hyprpaper)" || fail 'a single-check run failed'
jq -e '.schemaVersion == 1
and (.generatedAt | type == "string")
and (.summary.status | IN("healthy", "warning", "error"))
and (.context.session | IN("hyprland", "other"))
and (.context.versions | type == "array")
and (.checks | length) == 1
and (.checks[0] | has("id") and has("group") and has("title") and has("status") and has("detail"))
and .checks[0].id == "desktop.hyprpaper"' \
>/dev/null <<<"$single" || fail "a single check is not the full report shape: $single"
[[ "$(jq -r '.summary | [.healthy, .warnings, .errors, .unconfigured] | add' <<<"$single")" == "1" ]] \
|| fail 'the single-check summary counts something other than the one check it ran'
! grep -Fq 'fixture-secret-token' <<<"$single" || fail 'a single check exposed a fixture secret'
# An id nobody authored is refused rather than answered with an empty
# snapshot, which would look valid and say nothing. The refusal is an exit
# status and a message, not a report: a caller that asked for a check that does
# not exist has a bug, and handing it a well-formed reply hides it.
for rejected_id in unknown.check ../../escape 'desktop.vicinae;touch injected' ''; do
before_state="$(fixture_state)"
set +e
single_rejected="$(run_doctor check "$rejected_id" 2>"$fixture/single-check-error")"
single_status=$?
set -e
[[ "$single_status" != 0 ]] \
|| fail "the single-check verb accepted \"$rejected_id\""
[[ -z "$single_rejected" ]] \
|| fail "the single-check verb printed a report for \"$rejected_id\": $single_rejected"
[[ -s "$fixture/single-check-error" ]] \
|| fail "the single-check verb refused \"$rejected_id\" without saying why"
rm -f "$fixture/single-check-error"
[[ "$(fixture_state)" == "$before_state" ]] \
|| fail "the single-check verb mutated the filesystem for \"$rejected_id\""
done
# And it is not a second way to run the whole scan, or to run a repair.
run_doctor check desktop.hyprpaper --summary >/dev/null 2>&1 \
&& fail 'the single-check verb accepted a second output mode'
run_doctor check desktop.vicinae --repair desktop.vicinae >/dev/null 2>&1 \
&& fail 'the single-check verb accepted a repair alongside it'
printf 'panama doctor contract: PASS\n'
+96
View File
@@ -198,6 +198,102 @@ jq -e 'has("pwned") | not' "$settings" >/dev/null || fail 'a file outside the ba
# ── A snapshot that is not listed is refused ─────────────────────────────────
run restore "settings-20000101-000000000.json" >/dev/null 2>&1 && fail 'a missing snapshot was reported restored'
# ── A snapshot can be given a name, and the name cannot be a path ────────────
#
# Fifteen rows reading "2026-08-24 11:03:07" are fifteen rows nobody can choose
# between, which is the same as having no backups: the one you want is the one
# you took before the thing you are undoing, and the timestamp does not say
# what that was. So `create` takes a name.
#
# A name typed by a person then reaches a filename, which is the oldest way a
# helper gets talked into writing outside its own directory. The charset is the
# snapshot charset -- letters, digits, dot, dash, underscore -- and everything
# else is either sanitized out or refused; either is fine, writing outside
# BACKUP_DIR is not.
rm -f "$backups"/*.json
printf '{"gapsOut":24}' >"$settings"
printf '{"initialized":true,"favorites":[]}' >"$home"
run create >/dev/null || fail 'create without a name failed'
[[ "$(run list | jq 'length')" == "1" ]] || fail 'an unnamed create did not produce one snapshot'
run create "Before the theme experiment" >/dev/null || fail 'create with a name failed'
named="$(run list | jq -r '.[0]')"
jq -e '.label == "Before the theme experiment"' <<<"$named" >/dev/null \
|| fail "a named snapshot did not carry its name back: $named"
named_file="$(jq -r .name <<<"$named")"
[[ "$named_file" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a named snapshot produced the filename \"$named_file\", which is outside the snapshot charset"
# Every one of these either fails or produces a file inside the backup
# directory. None of them may produce a file anywhere else, and none may
# remove or overwrite something that is not a snapshot.
outside="$work/state/panama/NOT-A-BACKUP"
printf 'untouched\n' >"$outside"
for hostile in \
'../../../NOT-A-BACKUP' \
'/etc/panama-owned' \
'a/b' \
'..' \
'.' \
$'tab\there' \
'-rf'; do
run create "$hostile" >/dev/null 2>&1 || true
[[ "$(<"$outside")" == 'untouched' ]] \
|| fail "the snapshot name \"$hostile\" wrote outside the backup directory"
done
while read -r listed; do
[[ -n "$listed" ]] || continue
[[ "$listed" =~ ^[A-Za-z0-9_.-]+$ ]] \
|| fail "a hostile snapshot name produced the listed filename \"$listed\""
done < <(run list | jq -r '.[].name')
# ── A snapshot can be deleted, and only a snapshot ───────────────────────────
#
# Same boundary as restore, in the other direction, and with a worse failure:
# restore reading the wrong file overwrites settings, delete resolving the
# wrong name destroys something that is not a backup at all.
before_delete="$(run list | jq 'length')"
(( before_delete >= 2 )) || fail 'not enough snapshots to exercise delete'
victim="$(run list | jq -r '.[0].name')"
run delete "$victim" >/dev/null || fail 'deleting a listed snapshot failed'
[[ ! -e "$backups/$victim" ]] || fail 'a deleted snapshot is still on disk'
[[ "$(run list | jq 'length')" == "$((before_delete - 1))" ]] \
|| fail 'delete removed a different number of snapshots than one'
printf '{"pwned":false}' >"$work/delete-target.json"
for hostile in \
'../../delete-target.json' \
'/etc/passwd' \
"$victim" \
'settings-20000101-000000000.json' \
'' ; do
run delete "$hostile" >/dev/null 2>&1 \
&& fail "delete accepted \"$hostile\", which is not a snapshot in the backup directory"
done
[[ -f "$work/delete-target.json" ]] || fail 'delete followed a traversing name out of the backup directory'
escape="settings-20000101-000000009.json"
ln -s "$work/delete-target.json" "$backups/$escape"
run delete "$escape" >/dev/null 2>&1 \
&& fail 'delete accepted a symlink escaping the backup directory'
[[ -f "$work/delete-target.json" ]] || fail 'delete removed the target of an escaping symlink'
rm -f "$backups/$escape"
# ── The list says how much room a snapshot takes ─────────────────────────────
#
# Fifteen snapshots of a settings file are nothing; fifteen of a settings file
# somebody grew are not, and the page offers a Delete button now, which is a
# decision nobody can make without the size.
run list | jq -e 'all(.[]; (.bytes | type == "number") and .bytes > 0)' >/dev/null \
|| fail 'the snapshot list does not report each snapshots size'
listed_bytes="$(run list | jq -r '.[0].bytes')"
actual_bytes="$(stat -c %s "$backups/$(run list | jq -r '.[0].name')")"
[[ "$listed_bytes" == "$actual_bytes" ]] \
|| fail "the list reports $listed_bytes bytes for a snapshot that is $actual_bytes on disk"
# ── Snapshots are capped ─────────────────────────────────────────────────────
for _ in $(seq 1 20); do
printf '{"n":%s}' "$RANDOM" >"$settings"
@@ -173,6 +173,91 @@ status="$(qs_test ipc call settings-backup-behavior status)"
jq -e '.calls == [] and (.lastError | contains("display change"))' <<<"$status" >/dev/null \
|| fail "display-busy restore refusal was not clean: $status"
# ── The colour half of a display layout ─────────────────────────────────────
#
# Displays persists eleven fields per output. A restore read seven of them.
#
# The four it dropped -- VRR mode, colour profile, bit depth, SDR brightness
# and saturation, plus the mirror source -- are the ones nobody notices going
# missing, because the picture is still there and it is still the right size.
# Worse, `layoutsEqual` compared the same seven, so a snapshot whose colour
# settings differed from the live state compared EQUAL: the restore took the
# early return, decided there was nothing to apply, and reported success while
# leaving HDR off. A silent no-op that says it worked is the failure mode this
# whole codebase keeps relearning, and this is it in the one place where the
# evidence is a monitor looking slightly wrong.
#
# Both halves are asserted, because fixing either alone leaves the bug: reading
# the fields without comparing them means the restore never runs; comparing
# them without reading them means it runs and applies nothing.
full='{
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false,
"vrrMode":2,"colorProfile":"hdr","bitdepth":10,"sdrBrightness":1.2,
"sdrSaturation":0.9,"mirrorOf":""},
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true,
"vrrMode":0,"colorProfile":"srgb","bitdepth":8,"sdrBrightness":1,
"sdrSaturation":1,"mirrorOf":"DP-2"}
}'
plain='{
"DP-2": {"mode":"4500x3000@60","scale":1.5,"transform":0,"x":-2560,"y":0,"primary":false},
"HDMI-A-1": {"mode":"2560x1440@60","scale":1,"transform":0,"x":0,"y":0,"primary":true}
}'
qs_test ipc call settings-backup-behavior reset >/dev/null
restored="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$full")")"
jq -e '
(. != null) and (length == 2)
and (.[] | select(.name == "DP-2")
| .vrrMode == 2 and .colorProfile == "hdr" and .bitdepth == 10
and .sdrBrightness == 1.2 and .sdrSaturation == 0.9 and .mirrorOf == "")
' <<<"$restored" >/dev/null \
|| fail "a stored layout's colour fields did not survive the trip back into a live layout: $restored"
jq -e '.[] | select(.name == "HDMI-A-1") | .mirrorOf == "DP-2" and .bitdepth == 8' \
<<<"$restored" >/dev/null \
|| fail "the mirror source did not survive the trip back into a live layout: $restored"
# Back-compat, in the same shape Displays.isPersistedLayoutEntry uses: a record
# written before these fields existed is still a record. Refusing it would turn
# every snapshot anybody already has into one that cannot be restored.
legacy="$(qs_test ipc call settings-backup-behavior layoutFor "$(jq -c . <<<"$plain")")"
jq -e '(. != null) and (length == 2) and (.[] | select(.name == "DP-2") | .scale == 1.5)' \
<<<"$legacy" >/dev/null \
|| fail "a stored layout without the colour fields was refused, so older snapshots cannot be restored: $legacy"
# And the comparison. Two layouts identical but for a colour profile are not
# equal -- this is the assertion the shipped behaviour FAILS, and the reason
# the restore silently did nothing.
# The two layouts travel as one object: the IPC client turns a top-level JSON
# array into one argument per element, so passing two layouts as two arguments
# arrives as four.
compare() {
qs_test ipc call settings-backup-behavior layoutsMatch \
"$(jq -c -n --argjson left "$1" --argjson right "$2" '{left: $left, right: $right}')"
}
drifted="$(jq -c '.[0].colorProfile = "srgb"' <<<"$restored")"
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|| fail 'layoutsEqual calls two layouts equal when their colour profiles differ, so a restore that should change the picture takes the early return and reports success'
for field in vrrMode bitdepth sdrBrightness sdrSaturation mirrorOf; do
case "$field" in
mirrorOf) drifted="$(jq -c --arg f "$field" '.[0][$f] = "HDMI-A-1"' <<<"$restored")" ;;
*) drifted="$(jq -c --arg f "$field" '.[0][$f] = 7' <<<"$restored")" ;;
esac
[[ "$(compare "$restored" "$drifted")" == "false" ]] \
|| fail "layoutsEqual ignores $field, so a snapshot differing only in it restores nothing"
done
[[ "$(compare "$restored" "$restored")" == "true" ]] \
|| fail 'layoutsEqual no longer calls a layout equal to itself, which would make every restore reapply the geometry it already has'
# The tolerance on the two float fields is a tolerance, not an exemption: a
# value that came back one ulp different is the same value, a value somebody
# changed is not.
nudged="$(jq -c '.[0].sdrBrightness = 1.2000001' <<<"$restored")"
[[ "$(compare "$restored" "$nudged")" == "true" ]] \
|| fail 'a float that came back with a rounding difference is treated as a change, so every restore would reapply the layout it already has'
trap - EXIT
cleanup
printf 'settings backup live contract: PASS\n'
+132 -3
View File
@@ -52,11 +52,33 @@ tabless_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*", icon: "[^"]*", tabs
tab_ids="$(grep -oE '\{ page: "[a-z-]+", label: "[^"]*" \}' "$routes" \
| sed -E 's/\{ page: "([a-z-]+)".*/\1/')"
# A third shape: a leaf that is reachable but is not a tab of anything. The
# manual is the only one -- it is reference material opened from About's Manual
# card and from deep links, not a control surface worth a permanent slot in the
# System strip. It still has to resolve, still has to have a SettingsShell case,
# and still has to be addressable by every caller holding its id, so it is a
# leaf for every purpose below except appearing in a tab strip.
hidden_block="$(sed -n '/property var hiddenLeaves:/,/\]/p' "$routes")"
hidden_ids="$(grep -oE 'page: "[a-z-]+"' <<<"$hidden_block" | sed -E 's/page: "([a-z-]+)"/\1/')"
[[ -n "$category_ids" ]] || fail 'no categories found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$tab_ids" ]] || fail 'no tabs found in SettingsRoutes -- this contract is not reading it correctly'
[[ -n "$hidden_ids" ]] \
|| fail 'no hiddenLeaves found in SettingsRoutes -- the manual is a routable non-tab leaf and this contract is not reading the mechanism that makes it one'
# The leaves: every page a person can actually land on.
leaves="$(printf '%s\n%s\n' "$tabless_ids" "$tab_ids" | sed '/^$/d')"
leaves="$(printf '%s\n%s\n%s\n' "$tabless_ids" "$tab_ids" "$hidden_ids" | sed '/^$/d')"
# A hidden leaf that is also a tab, or also a tabless category, puts one page
# in two places -- and unlike the tab/category overlap below, nothing visible
# would show it, because the hidden half draws no row anywhere.
while read -r page; do
[[ -n "$page" ]] || continue
grep -qx "$page" <<<"$tab_ids" \
&& fail "\"$page\" is a hidden leaf and also a tab, so the same page id names two different places"
grep -qx "$page" <<<"$tabless_ids" \
&& fail "\"$page\" is a hidden leaf and also a category of its own"
done <<<"$hidden_ids"
# ── The taxonomy addresses each leaf exactly once ─────────────────────────────
# ShellState.settingsPage holds a leaf id, and the sidebar highlights the
@@ -103,6 +125,50 @@ while read -r pair; do
fi
done <<<"$retired_pairs"
# ── The System strip is eight tabs, and the two it lost are still reachable ──
#
# System had grown to ten tabs, which is more than a strip can show without
# becoming a second sidebar. Two left: Region & Language merged into Date &
# Time, because a date format and the clock that shows it are one subject, and
# the Manual became a hidden leaf.
#
# The count is pinned rather than derived because the number is the point: this
# is the horizontal space one row of tabs has. Anything that needs an eleventh
# subject needs a decision, not another entry.
python3 - "$routes" <<'PY' || fail 'the System category is not the approved eight-tab strip'
import re
import sys
expected = [
"about", "updates", "services", "storage",
"snapshots", "containers", "datetime", "sync",
]
text = open(sys.argv[1], encoding="utf-8").read()
block = re.search(r'\{ page: "system",.*?tabs: \[(.*?)\n \] \}', text, re.S)
if not block:
raise SystemExit("the System category could not be read")
found = re.findall(r'\{ page: "([a-z-]+)", label: "[^"]*" \}', block.group(1))
if found != expected:
raise SystemExit(f"System tabs are {found}, expected {expected}")
PY
# `region` retiring is only safe because every caller still holding it lands on
# the tab that absorbed it. The generic retired-map check above proves the
# target is a leaf; this proves it is the RIGHT leaf, which is the half a
# rename cannot get wrong quietly.
grep -qE '"region"[[:space:]]*:[[:space:]]*"datetime"' <<<"$retired_block" \
|| fail 'the retired "region" id does not resolve to "datetime", so every Vicinae command, deep link, and search result holding it lands on Home'
[[ ! -e "$settings_dir/RegionPage.qml" ]] \
|| fail 'RegionPage.qml still exists, so the retired route has a live page behind it after all'
# The manual is a leaf but not a tab. Said both ways: a strip entry would put
# reference material back in the System strip that the merge just freed, and
# losing the leaf would break About's Manual card and every deep link.
grep -qx 'manual' <<<"$hidden_ids" \
|| fail 'the manual is not a hidden leaf, so opening it from About or a deep link has no destination'
grep -qx 'manual' <<<"$tab_ids" \
&& fail 'the manual is a tab again, which is the System strip slot the consolidation just freed'
# ── Every leaf resolves everywhere ───────────────────────────────────────────
while read -r page; do
[[ -n "$page" ]] || continue
@@ -153,5 +219,68 @@ while read -r page_file; do
|| fail "$type_name.qml exists but nothing in SettingsShell instantiates it"
done < <(find "$settings_dir" -maxdepth 1 -name '*Page.qml')
printf 'settings nav contract: PASS (%d categories, %d leaves, %d retired ids)\n' \
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" "$retired_count"
# ── The search index lands on leaves, not on ids that used to be leaves ──────
#
# SettingsSearch is the fifth place a page id is written down, and the only one
# where being wrong is silent: `resolve()` turns anything it does not recognise
# into Home, so a result whose page id was retired still opens a window, still
# looks like it worked, and lands somewhere else. That is exactly what the two
# Region & Language entries would have done -- and they are the entries most
# likely to be searched for by somebody who could not find the setting.
#
# Checked against the leaves this file already derived, so a page consolidated
# next time cannot leave a search result pointing at its old name.
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
[[ -r "$search" ]] || fail "cannot read $search"
while read -r page; do
[[ -n "$page" ]] || continue
grep -qx "$page" <<<"$leaves" && continue
if grep -qE "\"[a-z-]+\"[[:space:]]*:[[:space:]]*\"$page\"" <<<"$retired_block"; then
fail "the search index routes to \"$page\", which is a retired id -- resolve() answers with its target, so the result lands somewhere the row never named"
fi
fail "the search index routes to \"$page\", which is not a leaf, so that result silently opens Home"
done < <({
grep -oE 'page: "[a-z-]+"' "$search"
sed -n '/property var groupPages:/,/})/p' "$search" | grep -oE ': "[a-z-]+"'
} | sed -E 's/.*"([a-z-]+)".*/\1/' | sort -u)
# The subjects the consolidation moved, each findable by its own name and each
# landing on the tab that now owns it. Region & Language merged into Date, Time
# & Region, so the words people arrive with for a format have to reach it; and
# About and Sync & Backup grew rows nobody could search for at all.
while IFS='|' read -r label page; do
[[ -n "$label" ]] || continue
python3 - "$search" "$label" "$page" <<'PY' \
|| fail "the search index does not offer \"$label\" on the $page page"
import re
import sys
text, label, page = open(sys.argv[1], encoding="utf-8").read(), sys.argv[2], sys.argv[3]
pattern = rf'\{{ label: "{re.escape(label)}",[^\n]*page: "([a-z-]+)" \}}'
match = re.search(pattern, text)
if not match:
raise SystemExit(f'no entry labelled "{label}"')
if match.group(1) != page:
raise SystemExit(f'"{label}" routes to {match.group(1)}, expected {page}')
PY
done <<'SEARCHABLE'
Hostname|about
Kernel version|about
Device model|about
Installed memory|about
Uptime|about
Serial number|about
Export settings|sync
Import settings|sync
Language|datetime
Regional formats|datetime
Currency|datetime
Measurement units|datetime
Paper size|datetime
First day of the week|datetime
Manual|manual
SEARCHABLE
printf 'settings nav contract: PASS (%d categories, %d leaves of which %d hidden, %d retired ids)\n' \
"$(grep -c . <<<"$category_ids")" "$(grep -c . <<<"$leaves")" \
"$(grep -c . <<<"$hidden_ids")" "$retired_count"
+25 -2
View File
@@ -13,7 +13,13 @@ fail() {
# rebuild touched all four Connections-category pages at once: three of them had
# never been checked for the page scaffold at all, and a rebuild is exactly when
# a hand-rolled Flickable comes back.
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About)
# Updates, DateTime and Containers joined when the System rebuild touched all
# of them at once. Containers is the one this check was waiting for: its root
# was a bare Item, so it had never had the page scaffold at all, and every
# convention the scaffold carries -- the scroll behaviour, the header, the
# padding -- was hand-rolled there and quietly different from the other
# twenty-five pages.
pages=(Home MyHome Phone Displays Connectivity Firewall Printers Sharing Bar Dock ControlCenter Tiling Workspaces Sync Sound Shortcuts Mouse Dictation Notifications Focus ScreenIntelligence Health About Updates DateTime Containers Manual)
for page in "${pages[@]}"; do
page_file="$repo_dir/config/dot/quickshell/modules/settings/${page}Page.qml"
[[ -f "$page_file" ]] || fail "${page}Page.qml is missing"
@@ -359,7 +365,13 @@ shell_pid="$harness_pid"
# four different categories, and the page the tab strip was introduced for.
# Routing to a tab must land on that tab, not on whatever its category opens
# first, which is the failure the SettingsRoutes resolution could introduce.
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about)
#
# The four System leaves at the end are the ones the consolidation moved.
# `updates` and `datetime` were rebuilt, `containers` changed its root type,
# and `manual` stopped being a tab and became a hidden leaf -- which is exactly
# the kind of change that keeps a page loading fine while making it
# unreachable, so each one is opened for real here.
pages=(home appearance displays connectivity my-home phone bar dock control-center tiling workspaces sync sound dictation notifications focus screen-intelligence shortcuts mouse services manual about updates datetime containers)
for page in "${pages[@]}"; do
qs_for_test ipc call settings page "$page" >/dev/null
for _ in $(seq 1 20); do
@@ -386,6 +398,17 @@ done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "bar" ]] \
|| fail 'the retired "desktop" id no longer resolves to the Bar tab'
# Region & Language merged into Date, Time & Region. Its id is held by two
# Vicinae commands, the deep links in the manual, and anybody who ever typed
# it, so it has to land on the tab that absorbed it rather than on Home.
qs_for_test ipc call settings page region >/dev/null
for _ in $(seq 1 20); do
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] && break
sleep 0.1
done
[[ "$(qs_for_test ipc call settings status | jq -r .page)" == "datetime" ]] \
|| fail 'the retired "region" id does not resolve to the Date, Time & Region tab'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Settings" and .key == "I" and .modmask == 64)' >/dev/null \
|| fail 'Super+I is not registered as Panama Settings'
/usr/sbin/hyprctl -j binds | jq -e '.[] | select(.description == "Screen Intelligence" and .key == "S" and .modmask == 65)' >/dev/null \
+62
View File
@@ -20,6 +20,14 @@
# including every numeric enum -- were accepted unchecked, and numeric
# enums were then refused outright once that was noticed.
# 5. Import is a merge. Settings the file does not mention are left alone.
# 6. The preview says what would change, in a shape a diff list can render.
# "12 settings would change" is a number, not an answer; the page now
# shows the rows, so `changes` carries {key, from, to} with both sides
# already turned into text. Doing that stringification in the helper
# rather than in QML is what makes the cap enforceable: a value long
# enough to be something other than a setting is truncated once, here,
# instead of being handed whole to a Text element and to anybody reading
# over a shoulder.
#
# Runs entirely against a temporary config home. The real settings store is read
# for the export and never written.
@@ -128,6 +136,60 @@ for key in ("gapsIn", "colorScheme", "vrrPolicy", "blurEnabled", "displays", "so
raise SystemExit(f'{key} was refused and queued for application anyway')
PY
# ── 6. The preview renders as a diff, and cannot render a secret whole ──────
python3 - "$bundle" "$work/oversized.json" <<'PY'
import json, sys
bundle = json.load(open(sys.argv[1]))
# A real, free-text, non-path string setting, so this exercises a value that
# genuinely travels rather than one the validator would refuse for its own
# reasons. 4000 characters is not a location; it is somebody's paste buffer.
bundle["settings"]["weatherLocation"] = "Bearer sk-fixture-secret-" + ("x" * 4000)
json.dump(bundle, open(sys.argv[2], "w"))
PY
"$helper" preview "$work/oversized.json" >"$work/oversized-preview.json" \
|| fail 'preview failed on a bundle carrying an oversized value'
python3 - "$work/preview.json" "$work/oversized-preview.json" <<'PY' || fail 'the preview does not describe changes in a shape a diff list can render safely'
import json
import sys
CAP = 200
for path in sys.argv[1:]:
plan = json.load(open(path))
if "changes" not in plan:
raise SystemExit('the preview does not say what would change')
if "changeCount" not in plan:
raise SystemExit('the preview lists changes without saying how many there are, '
'so a capped list reads as the whole truth')
count = plan["changeCount"]
if not isinstance(count, int) or count < 0:
raise SystemExit('changeCount is not a count')
if count != len(plan["apply"]):
raise SystemExit(f'changeCount says {count} but {len(plan["apply"])} would be applied')
if len(plan["changes"]) > count:
raise SystemExit('the rendered list is longer than the number of changes')
for entry in plan["changes"]:
if set(entry) != {"key", "from", "to"}:
raise SystemExit(f'a change carries {sorted(entry)}, expected key/from/to')
for side in ("key", "from", "to"):
if not isinstance(entry[side], str):
raise SystemExit(f'{entry["key"]}.{side} is {type(entry[side]).__name__}, '
'not text a row can render')
if len(entry[side]) > CAP:
raise SystemExit(f'{entry["key"]}.{side} is {len(entry[side])} characters; '
'an uncapped value reaches the screen whole')
oversized = json.load(open(sys.argv[2]))
rendered = json.dumps(oversized["changes"])
if "x" * (CAP + 1) in rendered:
raise SystemExit('an oversized value was reproduced in full in the change list')
PY
# A clean bundle must arrive intact. Refusing valid settings is the failure this
# contract exists to catch as much as accepting invalid ones -- fixing the enum
# check the first time turned every numeric enum into a rejection.
+145
View File
@@ -138,6 +138,151 @@ if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] ==
raise SystemExit('neither source produced anything, so nothing was parsed')
" || fail 'the update history is not usable'
# ── 5. A download size is the whole download, or it is not offered ──────────
#
# dnf5 prices packages from repository metadata, flatpak renders "36.2 MB" and
# has no machine-readable column at all, and either can come back short. A
# figure that covers some of what is pending, shown as the download, understates
# it -- and understating it is the direction that costs somebody money on a
# metered connection. So the total is present only when every item was priced.
printf '%s' "$state" | python3 -c "
import json, sys
state = json.load(sys.stdin)
for name, key in ((\"dnf\", \"packages\"), (\"flatpak\", \"applications\")):
source = state.get(name, {})
items = source.get(key, [])
if \"downloadBytes\" not in source:
continue
if not items:
raise SystemExit(name + \" priced an empty list\")
unpriced = [item for item in items if \"bytes\" not in item]
if unpriced:
raise SystemExit(name + \" reports a total while items have no size\")
total = sum(int(item[\"bytes\"]) for item in items)
if int(source[\"downloadBytes\"]) != total:
raise SystemExit(name + \" totals \" + str(source[\"downloadBytes\"]) + \" for items summing to \" + str(total))
" || fail 'a reported download size does not add up to the items it is a size for'
# ── 6. A changelog is a read ────────────────────────────────────────────────
#
# This verb takes a package name from a settings page and hands it to dnf, which
# is the one place in this helper where the page names the subject. Two things
# have to hold and neither is visible from reading the happy path: the name is
# constrained before it reaches argv, and nothing on this path installs, removes
# or upgrades anything.
#
# Proved by construction rather than by inspection: dnf5, flatpak and pkexec are
# replaced with stubs that record their argv and produce nothing, so whatever
# the helper decides to run is written down and checked afterwards. A stub that
# answers nothing also exercises the honest-absence path, which is the common
# case on a machine with third-party repositories.
changelog_work="$(mktemp -d /tmp/panama-updates-changelog.XXXXXX)"
trap 'rm -rf "$changelog_work"' EXIT
changelog_bin="$changelog_work/bin"
changelog_log="$changelog_work/argv.log"
mkdir -p "$changelog_bin" "$changelog_work/cache/panama"
for tool in dnf5 flatpak fwupdmgr pkexec systemctl; do
cat >"$changelog_bin/$tool" <<EOF
#!/usr/bin/env bash
printf '%s\t%s\n' "\${0##*/}" "\$*" >>"$changelog_log"
exit 1
EOF
chmod +x "$changelog_bin/$tool"
done
run_changelog() {
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$changelog_work/cache" "$helper" "$@"
}
: >"$changelog_log"
answer="$(run_changelog changelog dnf zsh)" || fail 'the changelog verb failed'
jq -e '.source == "dnf" and .name == "zsh" and .error == ""
and (.kind | IN("advisory", "changelog", "none"))
and (.text | type == "string")' <<<"$answer" >/dev/null \
|| fail "a changelog answered in an unusable shape: $answer"
[[ "$(jq -r .kind <<<"$answer")" == "none" ]] \
|| fail "a source that produced nothing was not reported as having no changelog: $answer"
[[ -n "$(jq -r .text <<<"$answer")" ]] \
|| fail 'a package with no changelog says nothing at all, which reads as a failure to load'
run_changelog changelog flatpak org.example.Fixture >/dev/null \
|| fail 'the flatpak changelog verb failed'
run_changelog changelog firmware FixtureDevice >/dev/null \
|| fail 'the firmware changelog verb failed'
python3 - "$changelog_log" <<'PY' || fail 'reading a changelog runs something that changes this machine'
import sys
mutating = {
"install", "remove", "erase", "upgrade", "update", "reinstall", "downgrade",
"autoremove", "distro-sync", "swap", "-y", "--assumeyes", "--noninteractive",
}
lines = [line.rstrip("\n") for line in open(sys.argv[1], encoding="utf-8") if line.strip()]
if not lines:
raise SystemExit("no commands were recorded, so this proves nothing")
for line in lines:
executable, _, arguments = line.partition("\t")
if executable == "pkexec":
raise SystemExit("a changelog asked for privilege")
for token in arguments.split():
if token in mutating:
raise SystemExit(f"{executable} was run with {token!r} while reading a changelog")
PY
# A name the machine would not have produced never reaches argv.
: >"$changelog_log"
for hostile in '../../etc/passwd' '/etc/passwd' 'zsh; rm -rf /' '' '-rf'; do
refusal="$(run_changelog changelog dnf "$hostile" 2>/dev/null)" \
|| fail "the changelog verb crashed on \"$hostile\" instead of refusing it"
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|| fail "the changelog verb accepted the name \"$hostile\""
done
[[ ! -s "$changelog_log" ]] || fail "a refused changelog name still started a process: $(<"$changelog_log")"
[[ -n "$(run_changelog changelog nonsense zsh | jq -r '.error // ""')" ]] \
|| fail 'an unknown changelog source was accepted'
# ── 7. One application, updated by name ─────────────────────────────────────
#
# `apply flatpak` updated everything, so the only way to take one application's
# update was to take all of them -- including the 900 MB one nobody asked about.
# The per-application verb appends exactly one ID to the same command, and the
# ID is checked against the last scan rather than trusted: the page is not the
# authority on what is pending, and this is the only verb that takes a name
# from it.
printf '%s' '{"flatpak":{"available":true,"count":1,"applications":[{"id":"org.example.Fixture","version":"1.0","origin":"flathub"}]}}' \
>"$changelog_work/cache/panama/updates.json"
: >"$changelog_log"
run_changelog apply flatpak org.example.Fixture >/dev/null \
|| fail 'a per-application update failed to answer'
# Only the flatpak lines: the refusal path re-reads the snapshot afterwards,
# which asks systemctl about the two unattended-update timers.
[[ "$(grep -c $'^flatpak\t' "$changelog_log")" == "1" ]] \
|| fail "a per-application update ran flatpak more than once: $(<"$changelog_log")"
grep -Fxq "$(printf 'flatpak\tupdate -y --noninteractive org.example.Fixture')" "$changelog_log" \
|| fail "the per-application argv was not exact: $(<"$changelog_log")"
: >"$changelog_log"
refusal="$(run_changelog apply flatpak org.example.NotPending)"
[[ -n "$(jq -r '.error // ""' <<<"$refusal")" ]] \
|| fail 'an application with no pending update was accepted'
! grep -q $'^flatpak\t' "$changelog_log" \
|| fail "a refused per-application update still ran flatpak: $(<"$changelog_log")"
[[ -n "$(run_changelog apply dnf somepackage | jq -r '.error // ""')" ]] \
|| fail 'a per-item target was accepted for a source that cannot take one'
# ── 8. The service offers all three, and the stale comment is gone ──────────
for api in 'function changelogFor(source: string, name: string): var' \
'function applyFlatpakApp(id: string): void' \
'readonly property string downloadSize'; do
grep -Fq "$api" "$service" || fail "the Updates service does not expose: $api"
done
grep -Fq 'dnf-automatic is not installed' "$helper" \
&& fail 'panama-updates still says dnf-automatic is not installed, which stopped being true when set-auto-dnf landed'
grep -q 'def set_auto_dnf' "$helper" \
|| fail 'automatic package updates are reported but cannot be turned on'
printf 'updates contract: PASS (%s dnf, %s flatpak, %s firmware; reboot needed: %s)\n' \
"$(jq -r '.dnf.count // 0' <<<"$state")" \
"$(jq -r '.flatpak.count // 0' <<<"$state")" \