#!/usr/bin/env bash
#
# check-installed.sh — assert that an *installed* machine is labelled the way
# the policy says it should be, and that the consequence of getting that wrong
# is actually absent.
#
# `bootc container lint` checks the image. On 2026-08-14 the image was fine and
# the machine was not: the installer's own configure stages run through a chroot
# with no `/sys` mounted, so libselinux concluded SELinux was off and
# shadow-utils wrote /etc/passwd, /etc/group, /etc/.pwd.lock, /etc/shadow and
# /etc/gshadow with no label, and `systemd-firstboot --root` did the same to
# /etc/hostname. Six files. Every build passed, because the defect was in what
# the installer wrote afterwards.
#
# Six mislabelled files would be a triviality if one of them were not
# `/etc/.pwd.lock`. systemd cannot take that lock as `etc_t`, so every unit with
# `DynamicUser=yes` fails to start, with no AVC in the journal because the
# denial is dontaudited. `rpm-ostreed.service` is one of those units, so the
# machine cannot layer a package at all: `alloy-layer-components.service` fails
# on the first boot after an install and the machine comes up with no console
# and no terminal, `rpm-ostree status` fails with `Could not activate remote
# peer`, and the hotfix channel is inert. The fix is `relabel_stages` in
# `crates/alloy/src/install.rs`; this script is the check that would have caught
# it the first time anyone installed, rather than on the day something finally
# needed DynamicUser.
#
# So it checks both ends, and both are cheap:
#
#   labels   `restorecon -nvR` over /etc and /usr, which reports what it would
#            change and changes nothing.
#   dynuser  a real `systemd-run -p DynamicUser=yes`, because that is the
#            property anyone cares about and no amount of label-reading proves
#            it as directly as running one.
#
# A third check answers a different standing question, and is separate because
# it needs no root and reports rather than passes or fails:
#
#   policy   what polkit's *implicit* defaults actually are on this machine,
#            for every action the console depends on.
#
# Wiki `alloy-privilege` records that table, read off a built Alloy image
# (NetworkManager 1.54.3, polkit 126). The whole privilege ladder is built on
# those values: tier 1 grants five actions precisely because their default is
# `auth_admin_keep`, and a Fedora override or an upstream change would make a
# shipped rules file either redundant or, worse, the only thing standing
# between a user and a prompt nobody can explain. The Containerfile asserts
# every row of POLICY_TABLE against the image's own `.policy` files on each
# build, reading the table out of this file. `--policy` asks the same question
# of a running machine, where a layered package or an `/etc` drop-in can differ
# from what the image shipped.
#
# Usage:
#   sudo build/check-installed.sh              # check this machine
#   build/check-installed.sh --policy          # read the polkit defaults back
#   build/check-installed.sh --self-test       # check the filter; touches nothing
#
# Or against a VM from the harness in build/vmtest:
#   ssh alloy-vm 'sudo bash -s' < build/check-installed.sh
#
# The self-test exists for the same reason check-rust-stage.sh has one: the
# filter is the part that fails invisibly, since a wrong verdict still looks
# like a verdict. Run it after touching IGNORE.
#
# Exit codes follow check-rust-stage.sh:
#
#   0  the machine is labelled correctly and DynamicUser works. Under
#      `--policy`, every action reads the way wiki `alloy-privilege` says.
#   1  it is not. Every offending line is printed, with the repair. Under
#      `--policy`, at least one action has drifted or is not defined here.
#   3  something about the run, not about the machine: not root, no
#      restorecon, or SELinux is not enforcing here. Prints `error:`.
#
# Why SELinux-off is 3 and not 0. Installing from the `selinux=0` GRUB entry
# (build/make-iso.sh) deliberately produces an unlabelled machine, and the
# installer skips relabelling there rather than failing, so that the escape
# hatch stays an escape hatch. A machine in that state is not passing this
# check; it is outside what the check can answer, and saying so is the honest
# result.

set -Eeuo pipefail

# Lines restorecon reports that are not a defect.
#
# A semanage transaction leaves empty lock files under the policy store,
# labelled by the process that made them rather than by the policy. They appear
# on any machine that has ever layered a package — which, on Alloy, is every
# machine after its first boot — and they are not related to anything here.
#
# Anchored on the policy store path rather than on the basename: a file called
# semanage.LOCK somewhere else in /etc would be a real finding, and this filter
# is the one place a real finding can be lost.
IGNORE='/etc/selinux/[^/ ]+/semanage\.[^/ ]*LOCK'

REPAIR='sudo restorecon -R /etc'

die() { printf 'error: %s\n' "$*" >&2; exit 3; }

trap 'rc=$?; [ "$rc" -eq 1 ] && exit 1; [ "$rc" -eq 3 ] && exit 3; die "unexpected failure at line ${LINENO}"' ERR

# Everything restorecon would change, minus the lines above. Reads stdin so the
# self-test can feed it a fixture instead of a machine.
filter_findings() { grep -Ev "$IGNORE" || true; }

# Fixtures are real: the six lines are the defect as it was found on 2026-08-14,
# and the two locks are what a correct machine reports after it has layered its
# components.
self_test() {
  local fails=0 out

  out="$(printf '%s\n' \
    'Would relabel /etc/passwd from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
    'Would relabel /etc/group from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
    'Would relabel /etc/.pwd.lock from unconfined_u:object_r:etc_t:s0 to system_u:object_r:passwd_file_t:s0' \
    'Would relabel /etc/shadow from unconfined_u:object_r:etc_t:s0 to system_u:object_r:shadow_t:s0' \
    'Would relabel /etc/gshadow from unconfined_u:object_r:etc_t:s0 to system_u:object_r:shadow_t:s0' \
    'Would relabel /etc/hostname from unconfined_u:object_r:var_run_t:s0 to system_u:object_r:hostname_etc_t:s0' \
    | filter_findings | wc -l)"
  if [ "$out" -ne 6 ]; then
    printf 'self-test: the six known mislabels should all survive the filter, %s did\n' "$out" >&2
    fails=$((fails + 1))
  fi

  out="$(printf '%s\n' \
    'Would relabel /etc/selinux/targeted/semanage.read.LOCK from system_u:object_r:semanage_store_t:s0 to system_u:object_r:selinux_config_t:s0' \
    'Would relabel /etc/selinux/targeted/semanage.trans.LOCK from system_u:object_r:semanage_store_t:s0 to system_u:object_r:selinux_config_t:s0' \
    | filter_findings | wc -l)"
  if [ "$out" -ne 0 ]; then
    printf 'self-test: the semanage locks should be filtered, %s survived\n' "$out" >&2
    fails=$((fails + 1))
  fi

  # The filter is anchored on the policy store, so a lock-shaped name elsewhere
  # is a finding. This is the assertion that keeps IGNORE from being widened
  # into something that hides one.
  out="$(printf '%s\n' \
    'Would relabel /etc/semanage.trans.LOCK from system_u:object_r:etc_t:s0 to system_u:object_r:selinux_config_t:s0' \
    | filter_findings | wc -l)"
  if [ "$out" -ne 1 ]; then
    printf 'self-test: a lock outside the policy store should survive the filter\n' >&2
    fails=$((fails + 1))
  fi

  if [ "$fails" -ne 0 ]; then
    printf 'self-test: %s failed\n' "$fails" >&2
    exit 1
  fi
  printf 'self-test: the filter is right about all three shapes\n'
}

# The policy table from wiki `alloy-privilege`, as `<action> <implicit active>`.
#
# Only the implicit defaults are read, not the effective answer. The effective
# answer on an Alloy machine includes
# `usr/share/polkit-1/rules.d/50-alloy-settings.rules`, which is asserted
# separately by `crates/alloy/tests/polkit_rules.rs` and by a Containerfile
# block. What this reads is the vendor default underneath: the five granted
# actions are granted *because* their default is `auth_admin_keep`, and a
# default that quietly became `yes` would make the rules file a security
# artifact shipped for no reason, while one that became `auth_admin` would mean
# the burst-caching the settings tab counts on is gone.
#
# This is the only copy of the table. The Containerfile's build assertion
# parses it out of this file rather than restating it, so the two cannot
# disagree.
#
# `NetworkManager.settings.modify.system` reads `yes` on the image, so saving a
# system connection needs neither a grant nor a prompt. It is in the table
# because the net views depend on that value holding.
#
# rpm-ostree and systemd1.manage-units are in the list without being granted
# anything: the first is what `alloy update` and `alloy pkg` go through and the
# prompt there is deliberate, and the second is what `run0` asks for, which is
# the command Alloy teaches instead of sudo.
POLICY_TABLE='
org.freedesktop.NetworkManager.network-control yes
org.freedesktop.NetworkManager.enable-disable-wifi yes
org.freedesktop.NetworkManager.enable-disable-network yes
org.freedesktop.NetworkManager.settings.modify.system yes
org.freedesktop.timedate1.set-timezone auth_admin_keep
org.freedesktop.timedate1.set-ntp auth_admin_keep
org.freedesktop.timedate1.set-time auth_admin_keep
org.freedesktop.hostname1.set-static-hostname auth_admin_keep
org.freedesktop.hostname1.set-machine-info auth_admin_keep
org.freedesktop.locale1.set-locale auth_admin_keep
org.freedesktop.locale1.set-keyboard auth_admin_keep
org.projectatomic.rpmostree1.upgrade auth_admin_keep
org.projectatomic.rpmostree1.rollback auth_admin_keep
org.projectatomic.rpmostree1.install-uninstall-packages auth_admin_keep
org.projectatomic.rpmostree1.client-management yes
org.freedesktop.systemd1.manage-units auth_admin_keep
'

# What polkit says the default is for one action, or `undefined` when this
# machine does not define it at all.
#
# An action nobody defines is the quiet failure worth catching: a grant naming
# a renamed action is inert and says nothing, and the symptom is a password
# prompt on a machine that was supposed to have none.
implicit_active() {
  local out
  out="$(pkaction --action-id "$1" --verbose 2>/dev/null)" || true
  [ -n "$out" ] || { printf 'undefined\n'; return; }
  printf '%s\n' "$out" | awk -F': *' '/implicit active:/ { print $2; found = 1 }
                                      END { if (!found) print "unreadable" }'
}

read_policy() {
  command -v pkaction >/dev/null 2>&1 || die "no pkaction: install polkit"

  local drift=0 absent=0 action expected actual mark
  while read -r action expected; do
    [ -n "$action" ] || continue
    actual="$(implicit_active "$action")"
    if [ "$actual" = "$expected" ]; then
      mark='ok     '
    elif [ "$actual" = undefined ]; then
      mark='ABSENT '
      absent=$((absent + 1))
    else
      mark='DRIFT  '
      drift=$((drift + 1))
    fi
    printf '%s %-56s %s\n' "$mark" "$action" "$actual"
  done <<< "$POLICY_TABLE"

  # Reported apart because they mean different things. DRIFT is the reading
  # this exists to catch: the default moved and the ladder is built on the old
  # one. ABSENT is usually the host, not the policy — run this on a machine
  # without rpm-ostree, as fw13 is, and the four rpm-ostree rows cannot be
  # answered by anything. On a booted Alloy image both are findings.
  if [ "$((drift + absent))" -ne 0 ]; then
    [ "$drift" -eq 0 ] || printf '\n%s action(s) read differently than wiki `alloy-privilege` records.\n' "$drift"
    [ "$absent" -eq 0 ] || printf '\n%s action(s) are not defined on this machine.\n' "$absent"
    printf 'That table decides which actions tier 1 grants, so correct the note before the rules file.\n'
    return 1
  fi
  printf '\nEvery action reads the way wiki `alloy-privilege` records it.\n'
}

case "${1:-}" in
  --self-test) self_test; exit 0 ;;
  # Spelled as a conditional rather than `read_policy; exit $?`, so a drift
  # verdict returns through the `if` instead of tripping the ERR trap.
  --policy) if read_policy; then exit 0; else exit 1; fi ;;
  '') ;;
  *) die "unknown argument: $1" ;;
esac

[ "$(id -u)" -eq 0 ] || die "run as root: restorecon cannot stat everything under /etc otherwise, and a partial read would pass"
command -v restorecon >/dev/null 2>&1 || die "no restorecon: install policycoreutils"
[ -d /sys/fs/selinux ] || die "SELinux is not enforcing on this machine, so there is nothing to compare against"

status=0

# -n changes nothing, -v prints what it would have changed. /usr as well as
# /etc: the 2026-08-14 defect was confined to /etc, and the reason anyone knew
# that was a clean /usr, so checking only the half that broke would leave the
# next one unmeasured.
findings="$(restorecon -nvR /etc /usr 2>/dev/null | filter_findings)"
if [ -n "$findings" ]; then
  printf 'mislabelled, %s file(s):\n' "$(printf '%s\n' "$findings" | wc -l)"
  printf '%s\n' "$findings"
  printf '\nrepair: %s\n' "$REPAIR"
  status=1
else
  printf 'labels: /etc and /usr match the policy\n'
fi

# The consequence, measured rather than inferred. `systemd-run --wait` returns
# the unit's own exit status, so a failure to *start* it is what is being
# caught here and /bin/true is only there to be something to start.
if command -v systemd-run >/dev/null 2>&1; then
  if err="$(systemd-run -q --wait --property=DynamicUser=yes /bin/true 2>&1)"; then
    printf 'dynuser: a DynamicUser unit starts\n'
  else
    printf 'dynuser: a DynamicUser unit does NOT start, so rpm-ostreed cannot run and nothing can be layered\n'
    printf '%s\n' "$err"
    printf '\nrepair: %s\n' "$REPAIR"
    status=1
  fi
else
  # Not fatal: the label half is the check, and this half is its confirmation.
  printf 'dynuser: skipped, no systemd-run\n'
fi

exit "$status"
