#!/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'
