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