#!/usr/bin/env bash # Updates come from three places that fail independently, and the page must not # claim to know more than it checked. # # The rules: # # 1. Every status a health check can return must be one the doctor counts. # "degraded" is not in its vocabulary; a check returning it was counted as # nothing at all while the summary still said healthy. That is the silent # no-op this whole codebase keeps relearning. # 2. A count nobody verified is not a count. "Up to date" may only be said # after a check actually ran. # 3. Applying packages takes a restore point first, and a failure to take one # must not block the update. # 4. Checking is separate from opening. A nine-second scan on every page open # would make Settings feel broken. # # Read-only: it reads update state and never installs anything. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-updates" service="$repo_dir/config/dot/quickshell/services/Updates.qml" page="$repo_dir/config/dot/quickshell/modules/settings/UpdatesPage.qml" doctor="$repo_dir/config/dot/quickshell/scripts/panama-doctor" fail() { printf 'updates contract: %s\n' "$1" >&2 exit 1 } for path in "$helper" "$service" "$page" "$doctor"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-updates is not executable' # ── 1. Every health status is one the doctor counts ───────────────────────── statuses="$(sed -n 's/^Status = Literal\[\(.*\)\]$/\1/p' "$doctor" | tr -d '" ' | tr ',' '\n' | grep -v '^$')" [[ -n "$statuses" ]] || fail 'could not read the doctor status vocabulary' while read -r used; do [[ -n "$used" ]] || continue grep -qx "$used" <<<"$statuses" \ || fail "a check reports status \"$used\", which the doctor does not count -- it would be invisible in the summary" done < <(grep -oE 'Check\("[a-z.]+", "[a-z-]+", "[^"]+", "[a-z]+"' "$doctor" \ | sed -E 's/.*, "([a-z]+)"$/\1/' | sort -u) # ── 2. No confident answer without a check ────────────────────────────────── grep -q 'everChecked' "$service" \ || fail 'the service cannot tell "no updates" from "never looked"' grep -q 'Not checked yet' "$service" \ || fail 'a machine that has never checked is reported as up to date' # ── 3. Packages get a restore point, best effort ──────────────────────────── apply_body="$(sed -n '/^def apply/,/^def /p' "$helper")" grep -q 'take_restore_point' <<<"$apply_body" \ || fail 'installing packages does not take a snapshot first' restore_body="$(sed -n '/^def take_restore_point/,/^def /p' "$helper")" grep -q 'return ""' <<<"$restore_body" \ || fail 'a failed snapshot has no non-fatal path, so it would block the update' grep -qE 'raise BoundaryError' <<<"$restore_body" \ && fail 'a failed snapshot aborts the update, which is worse than an update without a restore point' # Firmware and applications must NOT take a system snapshot: neither changes # the system tree, and a restore point that restores nothing is noise. grep -qE 'take_restore_point.*firmware|firmware.*take_restore_point' <<<"$apply_body" \ && fail 'firmware updates take a system snapshot, which would restore nothing' # ── 4. Opening is not checking ────────────────────────────────────────────── grep -q 'def snapshot' "$helper" || fail 'there is no cheap read' snapshot_body="$(sed -n '/^def snapshot/,/^def /p' "$helper")" grep -qE 'dnf_updates\(\)|flatpak_updates\(\)|firmware_updates\(\)' <<<"$snapshot_body" \ && fail 'the cheap read runs the expensive scan, so every page open would wait on the network' grep -q 'read_cache()' <<<"$snapshot_body" \ || fail 'the cheap read does not use the cached result' command -v jq >/dev/null 2>&1 || { printf 'updates contract: SKIP (no jq)\n'; exit 0; } # ── The snapshot is fast and complete ─────────────────────────────────────── started="$(date +%s)" state="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' elapsed=$(( $(date +%s) - started )) (( elapsed <= 5 )) || fail "the cheap read took ${elapsed}s; it is supposed to be instant" jq -e '(.dnf | type == "object") and (.flatpak | type == "object") and (.firmware | type == "object")' \ <<<"$state" >/dev/null || fail 'the snapshot is missing one of the three sources' jq -e '.kernel | has("running") and has("newestInstalled") and has("rebootNeeded")' <<<"$state" >/dev/null \ || fail 'the kernel state is incomplete' jq -e '.automatic | has("flatpakEnabled")' <<<"$state" >/dev/null \ || fail 'automatic update state is missing' # The reboot signal must be derived, not guessed. jq -e '.kernel.rebootNeeded == (.kernel.running != .kernel.newestInstalled)' <<<"$state" >/dev/null \ || fail 'the reboot signal does not follow from the running and installed kernels' # ── Refusals ──────────────────────────────────────────────────────────────── [[ -n "$("$helper" apply nonsense 2>/dev/null | jq -r '.error // ""')" ]] \ || fail 'an unknown update source was accepted' [[ -n "$("$helper" bogus 2>/dev/null | jq -r '.error // ""')" ]] \ || fail 'an unknown command was accepted' # ── Update history ────────────────────────────────────────────────────────── # # Worth showing because automatic updates leave no other trace: something that # installed itself overnight is invisible until somebody looks here. # # Both sources are asked in their own machine-readable form. flatpak's is the # awkward one -- it prints a JSON array whose timestamps are "Aug 20 08:07:46", # with no year -- so the year is inferred, and a date that would land in the # future is read as last year's. dnf's start_time is epoch UTC; its own table # prints that value as though it were local, which it is not. history="$("$helper" history)" || fail 'history failed' printf '%s' "$history" | python3 -c " import json, sys, time payload = json.load(sys.stdin) entries = payload['entries'] if not entries: raise SystemExit(0) # a machine with no transactions is legitimate now = int(time.time()) previous = None for entry in entries: if entry['source'] not in ('dnf', 'flatpak'): raise SystemExit(f\"unknown source {entry['source']}\") at = int(entry['at']) if at and at > now + 86400: raise SystemExit(f\"{entry['summary']} is dated in the future, so the year was read wrong\") if at and previous is not None and at > previous: raise SystemExit('entries are not newest first') if at: previous = at if not str(entry['summary']).strip(): raise SystemExit('an entry has nothing to say it did') if not any(e['source'] == 'flatpak' for e in entries) and not any(e['source'] == 'dnf' for e in entries): 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" <>"$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")" \ "$(jq -r '.firmware.count // 0' <<<"$state")" \ "$(jq -r '.kernel.rebootNeeded' <<<"$state")"