368 lines
20 KiB
Bash
Executable File
368 lines
20 KiB
Bash
Executable File
#!/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" <<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'
|
|
|
|
# ── 9. A check that failed is not an up-to-date machine ─────────────────────
|
|
#
|
|
# Every source used to answer a failure with an empty list and no error: dnf
|
|
# outside exit 0/100, flatpak against an unreachable remote, fwupdmgr printing
|
|
# nothing (its --json exits 0 on failure and says so in the payload). The page
|
|
# then added three zeroes together, said "Up to date", and stamped the clock --
|
|
# the reassuring wrong answer, on the one page whose whole job is security
|
|
# fixes.
|
|
#
|
|
# Proved with the same stubs the changelog section uses: every tool present and
|
|
# every tool failing.
|
|
check_work="$(mktemp -d /tmp/panama-updates-check.XXXXXX)"
|
|
trap 'rm -rf "$changelog_work" "$check_work"' EXIT
|
|
mkdir -p "$check_work/cache/panama"
|
|
|
|
run_check() {
|
|
PATH="$changelog_bin:$PATH" XDG_CACHE_HOME="$check_work/cache" "$helper" "$@"
|
|
}
|
|
|
|
failed_check="$(run_check check)" || fail 'check crashed instead of reporting the failure'
|
|
for source in dnf flatpak firmware; do
|
|
[[ -n "$(jq -r ".$source.error // \"\"" <<<"$failed_check")" ]] \
|
|
|| fail "$source reported no error after failing, so its empty list reads as nothing to do: $failed_check"
|
|
[[ "$(jq -r ".$source.count" <<<"$failed_check")" == "0" ]] \
|
|
|| fail "$source invented a count out of a failed check: $failed_check"
|
|
[[ "$(jq -r ".$source.checkedAt" <<<"$failed_check")" == "0" ]] \
|
|
|| fail "$source stamped its clock on a check that failed: $failed_check"
|
|
done
|
|
[[ "$(jq -r .checkedAt <<<"$failed_check")" == "0" ]] \
|
|
|| fail "a check where nothing answered still stamped the overall clock: $failed_check"
|
|
[[ "$(jq -r '.dnf.securityKnown' <<<"$failed_check")" == "false" ]] \
|
|
|| fail "a failed advisory query still claims the security count is known: $failed_check"
|
|
|
|
# A clean stamp already on record is CARRIED, not refreshed. "Checked 2 minutes
|
|
# ago" beside a stale count is the same lie wearing a timestamp.
|
|
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0,"securityKnown":true,"error":"","checkedAt":1000},"flatpak":{"available":true,"count":0,"applications":[],"error":"","checkedAt":1000},"firmware":{"available":true,"count":0,"devices":[],"error":"","checkedAt":1000}}' \
|
|
>"$check_work/cache/panama/updates.json"
|
|
stale_check="$(run_check check)" || fail 'check crashed over an existing cache'
|
|
[[ "$(jq -r .checkedAt <<<"$stale_check")" == "1000" ]] \
|
|
|| fail "a failed check moved the overall clock forward: $stale_check"
|
|
[[ "$(jq -r '.dnf.checkedAt' <<<"$stale_check")" == "1000" ]] \
|
|
|| fail "a failed source moved its own clock forward: $stale_check"
|
|
|
|
# An old cache with none of these fields must still read, rather than losing
|
|
# them at the surface where a missing error looks exactly like no error.
|
|
printf '%s' '{"checkedAt":1000,"dnf":{"available":true,"count":0,"packages":[],"securityCount":0}}' \
|
|
>"$check_work/cache/panama/updates.json"
|
|
legacy="$(run_check snapshot)" || fail 'snapshot crashed over a cache written before per-source errors'
|
|
jq -e '(.dnf | has("error") and has("checkedAt"))
|
|
and (.flatpak | has("error") and has("checkedAt"))
|
|
and (.firmware | has("error") and has("checkedAt"))' <<<"$legacy" >/dev/null \
|
|
|| fail "an older cache lost the per-source honesty fields: $legacy"
|
|
|
|
# The service must refuse "Up to date" while a source is unknown, and must ask
|
|
# that question BEFORE it asks whether the total is zero -- a failed source
|
|
# contributes zero, which is what made the two indistinguishable.
|
|
grep -Fq 'if (root.anySourceFailed) {' "$service" \
|
|
|| fail 'the summary does not consider a source that could not answer'
|
|
python3 - "$service" <<'PY' || fail 'the summary can still say "Up to date" over a source that never answered'
|
|
import sys
|
|
|
|
body = open(sys.argv[1], encoding="utf-8").read()
|
|
start = body.index("function summary()")
|
|
end = body.index("\n }", start)
|
|
summary = body[start:end]
|
|
if "anySourceFailed" not in summary:
|
|
raise SystemExit('summary() does not test anySourceFailed')
|
|
if summary.index("anySourceFailed") > summary.index('"Up to date"'):
|
|
raise SystemExit('summary() says "Up to date" before it asks whether a source failed')
|
|
PY
|
|
grep -Fq 'Updates.sourceError("flatpak") === ""' "$page" \
|
|
|| fail 'the applications row says "Current" without asking whether the list was read'
|
|
grep -Fq 'Updates.sourceError("firmware") === ""' "$page" \
|
|
|| fail 'the firmware row says "Current" without asking whether the list was read'
|
|
grep -Fq 'securityKnown' "$page" \
|
|
|| fail 'the headline says "nothing security-critical" without asking whether advisories were read'
|
|
|
|
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")"
|