#!/usr/bin/env bash # The Applications page can now install and remove software, which makes # `panama-applications` the most dangerous helper in the shell. Three things # have to hold, and each of them is a way a settings page could damage a machine # rather than manage it: # # 1. The catalog is the ONLY thing installable. A page that took an id from # anywhere else would be a general-purpose package installer wearing a # settings icon, and `pkexec dnf install -y $anything` is as bad as that # sounds. So an id the catalog does not contain is refused before any # command runs. # 2. The catalog the page reads is the SAME catalog `panama apps` and the # installer read. Two parsers over one file format drift, and the drift is # invisible: the page simply offers a slightly different list, or installs # a slightly different target. This contract runs both parsers over the # same files and compares the answers rather than reading either one. # 3. Removal is flatpak and nothing else. Removing a dnf package from a # settings page can take the desktop, the compositor, or the kernel with # it -- Settings says so honestly and prints the command instead. That is # only true while no removal path exists at all, so it is checked as an # absence in the source, not as a refusal at runtime: a refusal can be # bypassed by a later caller; a path that does not exist cannot. # # SAFETY. This runs the real helper against a fake machine, so it must be # impossible for it to reach the real one. Verified before anything runs: # # 1. every binary the helper names is resolved through PATH (asserted # statically -- an absolute /usr/bin/flatpak would walk straight past the # stubs); # 2. PATH's first entry is the stub directory, and flatpak, rpm, dnf, pkexec # and gio each resolve there; # 3. the helper is run under `env -i` with HOME and every XDG directory inside # the scratch tree, so anything it writes lands there; # 4. dnf, sudo and rpm-ostree are stubs that record and FAIL, so a real # package transaction is not merely unlikely, it exits non-zero and is # visible in the log. # # The catalog itself is a fixture, not `setup/packages/extras`: the point is to # exercise the format's corners (a labelled entry, an unlabelled one, a dnf # name, comments, blank lines, and indented continuations) rather than whatever # the shipped catalog happens to contain this week. # # Set PANAMA_APPLICATIONS_STATIC_ONLY=1 to run only the source-reading half, # which touches nothing at all. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-applications" service="$repo_dir/config/dot/quickshell/services/AppLibrary.qml" catalog_lib="$repo_dir/setup/lib/extras-catalog" fail() { printf 'app library contract: %s\n' "$1" >&2 exit 1 } for path in "$helper" "$service" "$catalog_lib"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-applications is not executable' # ── Static: the helper cannot walk past the stubs ──────────────────────────── # # Checked first because the dynamic half's safety rests on it. absolute="$(grep -nE '"/(usr/)?s?bin/[a-z0-9-]+"' "$helper")" [[ -z "$absolute" ]] \ || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" # ── Static: there is no removal path for system packages ───────────────────── # # The page's honest refusal row is a promise that Settings cannot do this. The # promise is kept by the code not existing. `rpm` is allowed, but only to ask a # question: -q and nothing else. python3 - "$helper" <<'PY' || fail 'the helper can remove a system package' import ast import sys source = open(sys.argv[1], encoding="utf-8").read() tree = ast.parse(source) REMOVAL = {"remove", "erase", "autoremove", "-e", "--erase", "history", "rollback"} findings = [] for node in ast.walk(tree): if not isinstance(node, (ast.List, ast.Tuple)): continue literals = [element.value for element in node.elts if isinstance(element, ast.Constant) and isinstance(element.value, str)] words = set(literals) if {"dnf", "rpm-ostree", "yum"} & words: offending = words & REMOVAL if offending: findings.append(f"line {node.lineno}: dnf command with {sorted(offending)}") if "rpm" in words: # A query is a question. Anything else is a transaction. if not ({"-q", "-qa", "--query"} & words) or (words & REMOVAL): findings.append(f"line {node.lineno}: rpm invoked for something other than a query: {literals}") if findings: print("; ".join(findings), file=sys.stderr) raise SystemExit(1) PY # Even in a string that never becomes a command list. There is no reason for # these words to be in this file, and a helper that builds its argv from a # format string would slip past the check above. forbidden="$(grep -nE '(dnf|rpm-ostree|yum)[^\n]{0,40}(remove|erase|autoremove)|rpm[^\n]{0,20} -e' "$helper")" [[ -z "$forbidden" ]] \ || fail "the helper spells out a package removal: $forbidden" # ── Static: uninstall is flatpak, alone ────────────────────────────────────── python3 - "$helper" <<'PY' || fail 'uninstall does not build a flatpak-only command' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) target = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name in ("uninstall", "uninstall_app")), None) if target is None: print("no uninstall function", file=sys.stderr) raise SystemExit(1) OTHER = {"dnf", "rpm", "yum", "pkexec", "sudo", "rpm-ostree", "sh", "bash"} commands = [] for node in ast.walk(target): if not isinstance(node, (ast.List, ast.Tuple)): continue literals = [element.value for element in node.elts if isinstance(element, ast.Constant) and isinstance(element.value, str)] if literals and literals[0] in OTHER: print(f"line {node.lineno}: uninstall reaches for {literals[0]}", file=sys.stderr) raise SystemExit(1) if literals and literals[0] == "flatpak": commands.append(literals) if not commands: print("uninstall never builds a flatpak command", file=sys.stderr) raise SystemExit(1) if not any("uninstall" in command and "--noninteractive" in command for command in commands): print(f"uninstall is not a non-interactive flatpak uninstall: {commands}", file=sys.stderr) raise SystemExit(1) PY # ── Static: the service's shape, and its seam ──────────────────────────────── grep -q 'pragma Singleton' "$service" || fail 'AppLibrary is not a singleton' grep -q 'PANAMA_APPLICATIONS_HELPER' "$service" \ || fail 'the service has no helper-path seam, so nothing can point it at a stub' grep -qE 'command\s*:\s*"' "$service" \ && fail 'Process command must be an argument array, or an application id becomes shell' for needle in 'property string lastError' 'function permissionsFor' 'function uninstall' \ 'function install' 'function entriesFor'; do grep -q "$needle" "$service" || fail "the service is missing: $needle" done if [[ "${PANAMA_APPLICATIONS_STATIC_ONLY:-0}" == "1" ]]; then printf 'app library contract: PASS (static)\n' exit 0 fi command -v jq >/dev/null 2>&1 || { printf 'app library contract: SKIP (no jq)\n'; exit 0; } command -v python3 >/dev/null 2>&1 || { printf 'app library contract: SKIP (no python3)\n'; exit 0; } # ── The fake machine ───────────────────────────────────────────────────────── work="$(mktemp -d /tmp/panama-app-library.XXXXXX)" stub_dir="$work/bin" state_dir="$work/state" home_dir="$work/home" extras_dir="$work/extras" mkdir -p "$stub_dir" "$state_dir" "$home_dir" "$extras_dir" \ "$work/config" "$work/data" "$work/cache" "$work/run" : >"$state_dir/argv" trap 'rm -rf "$work"' EXIT # The catalog, written to exercise the format rather than to be realistic. # Every line here is a rule in setup/lib/extras-catalog's header comment. cat >"$extras_dir/demo" <<'CATALOG' # A comment, and the blank line under it, are not entries. flatpak:org.example.Alpha | Alpha Editor flatpak:org.example.Bravo charlie-tool delta-tool | Delta flatpak:org.example.Echo | Echo Studio flatpak:org.example.Echo.Plugin.One flatpak:org.example.Echo.Plugin.Two CATALOG cat >"$extras_dir/more" <<'CATALOG' zulu-tool flatpak:org.example.Zulu | Zulu CATALOG # flatpak, recorded rather than performed. Which ids it claims are installed is # the fixture's business, not the helper's: `installed` in the catalog has to # come from asking, and this is what answers. cat >"$stub_dir/flatpak" <>"$state_dir/argv" joined="\$*" permissions() { case "\$1" in org.example.Permissive) printf '[Context]\n' printf 'shared=network;ipc;\n' printf 'sockets=x11;wayland;pulseaudio;\n' printf 'devices=all;\n' printf 'filesystems=host;\n' printf 'unrecognized-capability=yes;\n' printf '[Session Bus Policy]\n' printf 'org.freedesktop.Flatpak=talk\n' ;; org.example.Homey) printf '[Context]\n' printf 'sockets=wayland;\n' printf 'filesystems=home;\n' ;; *) printf '[Context]\n' printf 'sockets=wayland;\n' ;; esac } case "\$joined" in *"--show-permissions"*) for argument in "\$@"; do case "\$argument" in -*) ;; info) ;; *) permissions "\$argument"; exit 0 ;; esac done exit 0 ;; info*) # Only these four are on the fake machine. case "\$joined" in *org.example.Alpha*|*org.example.Permissive*|*org.example.Homey*|*org.example.Sandboxed*) exit 0 ;; esac printf 'error: %s not installed\n' "\$2" >&2 exit 1 ;; list*) printf 'org.example.Alpha\tAlpha Editor\t412.5 MB\tflathub\n' printf 'org.example.Permissive\tPermissive\t88.1 MB\tflathub\n' printf 'org.example.Homey\tHomey\t12.0 MB\tflathub\n' printf 'org.example.Sandboxed\tSandboxed\t4.2 MB\tflathub\n' exit 0 ;; esac exit 0 STUB # rpm answers questions and nothing else. Anything but a query is a failure the # log will show. One package on this fake machine is installed: charlie-tool, # which the catalog offers, so `installed` has something true to report. cat >"$stub_dir/rpm" <>"$state_dir/argv" case "\$1" in -qa|--query|-q) printf 'charlie-tool\nbash\nkernel\n' exit 0 ;; esac printf 'app library contract: rpm was asked to do something other than query\n' >&2 exit 1 STUB # pkexec records the privileged command it was handed and runs NOTHING. cat >"$stub_dir/pkexec" <>"$state_dir/argv" exit 0 STUB # Every other way out is closed rather than left open. for blocked in dnf yum sudo rpm-ostree gio flatpak-builder; do cat >"$stub_dir/$blocked" <>"$state_dir/argv" printf 'app library contract: the helper reached for $blocked\n' >&2 exit 1 STUB done chmod +x "$stub_dir"/* runh() { env -i \ PATH="$stub_dir:/usr/bin:/bin" \ HOME="$home_dir" \ XDG_CONFIG_HOME="$work/config" \ XDG_DATA_HOME="$work/data" \ XDG_CACHE_HOME="$work/cache" \ XDG_RUNTIME_DIR="$work/run" \ PANAMA_EXTRAS_DIR="$extras_dir" \ LANG=C LC_ALL=C \ "$helper" "$@" } # The safety claim, verified rather than assumed. for binary in flatpak rpm dnf pkexec sudo gio; do resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary")" [[ "$resolved" == "$stub_dir/$binary" ]] \ || fail "$binary resolves to '$resolved', not the stub; refusing to run against the real one" done # A refusal is a non-zero exit and a sentence on stderr, which is how every # helper in this repo says no. refused() { ! runh "$@" >/dev/null 2>&1; } log() { cat "$state_dir/argv"; } no_package_transaction() { local where="$1" grep -qE '^(dnf|yum|sudo|rpm-ostree) ' "$state_dir/argv" \ && fail "$where reached a package manager directly: $(log)" grep -qE '^rpm .*(-e|--erase|remove)' "$state_dir/argv" \ && fail "$where asked rpm to remove something: $(log)" return 0 } # ── The catalog is read by ONE set of rules ────────────────────────────────── # # Both parsers, the same files, compared line for line. Reading either one to # build the expectation would pass just as happily when both are wrong. source "$catalog_lib" catalog="$(runh catalog 2>/dev/null)" || fail 'catalog failed against the fixture' jq -e '(.categories | type == "array") and (.categories | length) == 2' <<<"$catalog" >/dev/null \ || fail "catalog did not report the two fixture categories: $catalog" expected_categories="$(catalog_categories "$extras_dir" | sort | tr '\n' ' ')" actual_categories="$(jq -r '.categories[] | if type == "object" then .name else . end' <<<"$catalog" \ | sort | tr '\n' ' ')" [[ "$expected_categories" == "$actual_categories" ]] \ || fail "the two parsers disagree about the categories: '$expected_categories' vs '$actual_categories'" for category in demo more; do # `targetlabel` from the shell library, turned into the id/label/kind # triple the page is given. The mapping is the claim: the id is the catalog # line's target VERBATIM -- `flatpak:` prefix and all, because that is the # string `install` matches against -- and the prefix becomes the kind. expected="$(catalog_entries "$extras_dir/$category" | while IFS=$'\t' read -r target label; do if [[ "$target" == flatpak:* ]]; then printf '%s\t%s\tflatpak\n' "$target" "$label" else printf '%s\t%s\tdnf\n' "$target" "$label" fi done)" actual="$(jq -r --arg category "$category" ' (.entries[$category] // (.categories[] | select(.name == $category) | .entries))[] | [.id, .label, .kind] | @tsv' <<<"$catalog" 2>/dev/null)" [[ -n "$actual" ]] || fail "the catalog reports no entries for '$category': $catalog" if [[ "$expected" != "$actual" ]]; then printf 'from setup/lib/extras-catalog:\n%s\nfrom panama-applications:\n%s\n' \ "$expected" "$actual" >&2 fail "the two catalog parsers disagree about '$category'" fi done # The indented lines belong to the entry above them. Listing them separately # would put fifteen OBS plugins in the menu as if they were applications. jq -e '[.. | objects | select(has("id")) | .id | select(contains(".Plugin."))] | length == 0' \ <<<"$catalog" >/dev/null \ || fail 'an indented continuation line is offered as an entry of its own' # `installed` is asked, not assumed: only what the stubs admit to is marked. installed="$(jq -r '[.. | objects | select(has("id") and has("installed")) | select(.installed) | .id] | sort | join(",")' <<<"$catalog")" [[ "$installed" == "charlie-tool,flatpak:org.example.Alpha" ]] \ || fail "installed state does not match what flatpak and rpm were willing to confirm: '$installed'" # ── Nothing outside the catalog can be installed ───────────────────────────── # # The refusal must happen before any command runs, which the empty log is what # proves. Checking only the exit code would pass with the guard deleted, since # the stub flatpak would fail on a nonsense id anyway. for bad in 'org.evil.Payload' 'charlie-tool; reboot' '--unused' '' '../../etc/passwd' \ 'flatpak:org.example.Echo.Plugin.One' 'org.example.Bravo' 'flatpak:org.evil.Payload'; do : >"$state_dir/argv" refused install demo "$bad" \ || fail "an id the catalog does not offer was accepted for install: ${bad@Q}" [[ ! -s "$state_dir/argv" ]] \ || fail "a refused install still ran something: ${bad@Q}: $(log)" done for bad_category in 'nonexistent' '../extras' '' 'demo/../more'; do : >"$state_dir/argv" refused install "$bad_category" flatpak:org.example.Bravo \ || fail "an unknown catalog category was accepted: ${bad_category@Q}" [[ ! -s "$state_dir/argv" ]] \ || fail "a refused category still ran something: ${bad_category@Q}: $(log)" done # An entry that exists, but in the other category, is still off-catalog here. : >"$state_dir/argv" refused install demo zulu-tool \ || fail 'an entry from another category was installed as if it belonged to this one' [[ ! -s "$state_dir/argv" ]] || fail "a cross-category install still ran something: $(log)" # ── What an accepted install actually runs ─────────────────────────────────── : >"$state_dir/argv" runh install demo flatpak:org.example.Bravo >/dev/null 2>&1 grep -Eq 'flatpak install .*--noninteractive.*flathub .*org\.example\.Bravo' "$state_dir/argv" \ || fail "installing a Flathub entry did not reach flatpak as expected: $(log)" no_package_transaction 'installing a flatpak' # The extensions ride along with the entry that owns them, and nothing else # does: an install that quietly pulled a neighbouring entry would make the # catalog's grouping a lie. : >"$state_dir/argv" runh install demo flatpak:org.example.Echo >/dev/null 2>&1 grep -Fq 'org.example.Echo' "$state_dir/argv" \ || fail "installing an entry with extensions did not install the entry: $(log)" strays="$(grep -oE 'org\.example\.[A-Za-z.]+' "$state_dir/argv" \ | grep -vE '^org\.example\.Echo(\.Plugin\.(One|Two))?$' | sort -u | tr '\n' ' ')" [[ -z "$strays" ]] || fail "installing one entry reached for another: $strays" # A dnf entry goes through polkit, and dnf is never run directly. : >"$state_dir/argv" runh install more zulu-tool >/dev/null 2>&1 grep -Eq '^pkexec .*dnf .*install .*zulu-tool' "$state_dir/argv" \ || fail "installing a package entry did not go through pkexec: $(log)" grep -qE '^dnf ' "$state_dir/argv" \ && fail "the helper ran dnf directly instead of asking polkit first: $(log)" # ── Removal is flatpak, and only for a flatpak ─────────────────────────────── : >"$state_dir/argv" runh uninstall org.example.Alpha >/dev/null 2>&1 grep -Eq 'flatpak uninstall .*--noninteractive.*org\.example\.Alpha' "$state_dir/argv" \ || fail "uninstall did not reach flatpak with the expected arguments: $(log)" no_package_transaction 'uninstalling an application' # A dnf package name is not an application id, whatever the caller believes. : >"$state_dir/argv" runh uninstall charlie-tool >/dev/null 2>&1 no_package_transaction 'uninstalling a system package name' for bad in '--unused' '-y' '' 'org.example.Alpha; reboot' '../../org.example.Alpha' \ "$(printf 'a%.0s' {1..300})"; do : >"$state_dir/argv" refused uninstall "$bad" \ || fail "uninstall accepted a malformed application id: ${bad@Q}" [[ ! -s "$state_dir/argv" ]] \ || fail "a malformed application id reached flatpak before being refused: ${bad@Q}: $(log)" done # ── Unused runtimes are listed without being removed ───────────────────────── # # The Storage page's cleanup row shows a size before anything happens, so the # listing verb has to be a question. A `flatpak uninstall --unused` here would # remove gigabytes at the moment the page merely rendered. : >"$state_dir/argv" runh unused-runtimes >/dev/null 2>&1 while read -r line; do [[ "$line" == flatpak* ]] || continue case "$line" in *--dry-run*|*list*|*info*) ;; *uninstall*) fail "listing unused runtimes actually removed them: $line" ;; esac done <"$state_dir/argv" : >"$state_dir/argv" runh clean-unused >/dev/null 2>&1 grep -Eq 'flatpak uninstall .*--unused' "$state_dir/argv" \ || fail "clean-unused does not remove unused runtimes: $(log)" grep -Eq 'flatpak uninstall .*--noninteractive' "$state_dir/argv" \ || fail "clean-unused would stop for a prompt nobody can answer: $(log)" # ── Permissions are summarized, and nothing is dropped ─────────────────────── # # The buckets are the point: "filesystems=host" and "filesystems=home" are one # character apart in the metadata and worlds apart in what they mean, so the # summary has to tell them apart rather than reporting "file access". permissive="$(runh permissions org.example.Permissive 2>/dev/null)" \ || fail 'permissions failed for an installed application' jq -e '(.summary | type == "array") and has("raw")' <<<"$permissive" >/dev/null \ || fail "permissions is missing its summary or its raw metadata: $permissive" summary_of() { jq -r '.summary | join(" | ")' <<<"$1"; } permissive_summary="$(summary_of "$permissive")" for bucket in 'Full file system access' 'Network' 'Devices' 'Camera' 'Microphone'; do grep -Fq "$bucket" <<<"$permissive_summary" \ || fail "a permission the application really has is missing from the summary ($bucket): $permissive_summary" done # Unknown keys are summarized honestly rather than dropped: a Flatpak feature # added next year must not silently become "this application asks for nothing". grep -Fq 'unrecognized-capability' <<<"$permissive" \ || fail 'a permission key the helper does not recognize disappeared entirely' homey="$(runh permissions org.example.Homey 2>/dev/null)" \ || fail 'permissions failed for a home-folder application' homey_summary="$(summary_of "$homey")" grep -Fq 'Home folder' <<<"$homey_summary" \ || fail "an application with home access is not described as having it: $homey_summary" grep -Fq 'Full file system access' <<<"$homey_summary" \ && fail "home access is being reported as access to the whole filesystem: $homey_summary" # And the summary is derived, not decorative: a sandboxed application says less. sandboxed="$(runh permissions org.example.Sandboxed 2>/dev/null)" sandboxed_summary="$(summary_of "$sandboxed")" for bucket in 'Full file system access' 'Network' 'Camera'; do grep -Fq "$bucket" <<<"$sandboxed_summary" \ && fail "a sandboxed application is credited with $bucket: $sandboxed_summary" done [[ "$(jq '.summary | length' <<<"$permissive")" -gt "$(jq '.summary | length' <<<"$sandboxed")" ]] \ || fail 'the permissive and the sandboxed application summarize the same, so nothing is being read' for bad in '' '--show-permissions' 'org.example.Alpha; reboot' '../escape'; do : >"$state_dir/argv" refused permissions "$bad" \ || fail "permissions accepted a malformed application id: ${bad@Q}" done # ── The installed list carries what the rows draw ──────────────────────────── flatpaks="$(runh flatpaks 2>/dev/null)" || fail 'flatpaks failed against the stub' jq -e 'type == "array" and length == 4' <<<"$flatpaks" >/dev/null \ || fail "flatpaks did not report the four installed applications: $flatpaks" jq -e '[.[] | has("id") and has("name") and has("size") and has("origin")] | all' \ <<<"$flatpaks" >/dev/null || fail "an installed application is missing part of its shape: $flatpaks" jq -e '[.[] | (.id | length > 0) and (.name | length > 0) and (.origin | length > 0)] | all' \ <<<"$flatpaks" >/dev/null || fail "an installed application has an empty field: $flatpaks" jq -e '[.[].id] | index("org.example.Alpha") != null' <<<"$flatpaks" >/dev/null \ || fail "the machine-readable listing was not parsed into ids: $flatpaks" # The size arrives as flatpak's own words ("412.5 MB") and as bytes beside them. # The bytes may be null -- a flatpak that cannot say is better than a number # invented for the sake of having one -- but never a string the page would then # have to parse a second time. jq -e '[.[].sizeBytes | type] | all(. == "number" or . == "null")' <<<"$flatpaks" >/dev/null \ || fail "an installed size is neither a number of bytes nor honestly absent: $flatpaks" jq -e '[.[] | select(.size == "412.5 MB") | .sizeBytes] | first > 400000000' <<<"$flatpaks" >/dev/null \ || fail "flatpak's own size was not parsed into bytes: $flatpaks" refused bogus-verb || fail 'an unknown command was accepted' # ── Nothing anywhere ran a package transaction ─────────────────────────────── : >"$state_dir/argv" runh catalog >/dev/null 2>&1 runh flatpaks >/dev/null 2>&1 no_package_transaction 'reading the catalog and the installed list' printf 'app library contract: PASS (catalog agreement, install refusals, flatpak-only removal, permission buckets)\n'