#!/usr/bin/env bash
#
# check-host.sh — ask a running Alloy machine the questions that used to cost a
# sitting, and answer them as pass or fail rows.
#
# Why this exists. On 2026-08-31 eighteen GoingsOn tasks were closed carrying
# `dropped:2026-08-31`, and every one of them was a verification that needed a
# person in front of a machine: press the Print binds, press the brightness
# keys, confirm three LUKS keyslots, confirm a .local name resolves. None was
# blocked on code. They piled up because the only machine running Alloy could
# not be reached from the machine doing the work.
#
# So the rule this script serves, from wiki `alloy-fleet-cutover`: no task whose
# output is an observation a person has to transcribe. Anything mechanical is a
# row here, and what is left for a person is only what genuinely needs eyes.
#
# Usage:
#   build/check-host.sh                  # this machine
#   ssh fw12 'bash -s' < build/check-host.sh
#   sudo build/check-host.sh             # adds the rows that need root
#   ssh -t fw12.local 'sudo bash -s' < build/check-host.sh   # both, with a prompt
#
# Four rows need root and skip without it: the image identity (`bootc status`
# refuses an unprivileged caller), the SELinux labels, the LUKS keyslot count,
# and the firewall's trusted-zone membership. Alloy's sudo asks for a password,
# so a BatchMode ssh run answers thirteen of seventeen rows and says so.
#   build/check-host.sh --self-test      # checks the harness; touches nothing
#
# Exit codes, following build/check-installed.sh:
#
#   0  every row that could run passed.
#   1  at least one row failed. The row says what and, where there is one, the
#      repair.
#   3  the question cannot be asked here: this is not an Alloy machine.
#
# SKIP is not a pass and not a failure. A row skips when the machine cannot be
# asked -- no graphical session over ssh, no root for a LUKS dump, no NVIDIA
# card -- and the count is printed at the end so a run that answered half the
# questions cannot read as a clean bill.
#
# What deliberately is NOT here: anything whose verdict is a judgement. Glyph
# crispness, the dark-mode L stops and whether 1.75x looks right are eyeball
# work, and a script that pretended to answer them would be worse than silence.

set -uo pipefail

PASSED=0
FAILED=0
SKIPPED=0

row() { # row STATUS NAME DETAIL...
  local status="$1" name="$2"; shift 2
  case "$status" in
    PASS) PASSED=$((PASSED + 1)) ;;
    FAIL) FAILED=$((FAILED + 1)) ;;
    SKIP) SKIPPED=$((SKIPPED + 1)) ;;
  esac
  printf '%-4s  %-16s  %s\n' "$status" "$name" "$*"
}

have() { command -v "$1" >/dev/null 2>&1; }
is_root() { [ "$(id -u)" -eq 0 ]; }

# The user whose configuration is being asked about, which is not root even
# when the run is. sudo leaves HOME=/root, so a row that reads $HOME asks
# whether root has a sway config and answers no on a machine that is correctly
# set up. That is how the privileged run on fw12 reported zero screenshot binds
# on 2026-09-03 while the unprivileged one found all four.
target_user() { echo "${SUDO_USER:-$(id -un)}"; }
# passwd first, $HOME only when passwd cannot answer. The order matters and is
# the reason $HOME is not simply used: under sudo it is /root, which is how a
# privileged run once reported a correctly configured machine as having no sway
# config. But an unnamed uid has no passwd entry at all -- a container run with
# `--user 1000:1000` against an image whose accounts the installer has not
# created yet -- and there the choice is $HOME or nothing.
target_home() {
  local home
  home="$(getent passwd "$(target_user)" 2>/dev/null | cut -d: -f6)"
  [ -n "$home" ] || home="${HOME:-}"
  echo "$home"
}
# A graphical session is not reachable over a plain ssh connection, which is
# how this script is usually run. Rows that need one skip rather than fail.
has_session() { [ -n "${WAYLAND_DISPLAY:-}" ] && have swaymsg; }

self_test() {
  local out fails=0
  out="$(PASSED=0; FAILED=0; SKIPPED=0; row FAIL x y >/dev/null; echo "$FAILED")"
  [ "$out" = "1" ] || { echo "self-test: a FAIL row did not count as a failure" >&2; fails=1; }
  out="$(PASSED=0; FAILED=0; SKIPPED=0; row SKIP x y >/dev/null; echo "$FAILED")"
  [ "$out" = "0" ] || { echo "self-test: a SKIP row counted as a failure" >&2; fails=1; }
  [ "$fails" -eq 0 ] && echo "self-test: ok"
  exit "$fails"
}

[ "${1:-}" = "--self-test" ] && self_test

# ---------------------------------------------------------------- identity ---
# Exit 3 rather than fail: a machine that is not Alloy is outside what any row
# below can answer, and saying so is the honest result.
. /etc/os-release 2>/dev/null || true
[ "${ID:-}" = "alloy" ] || { printf 'error: not an Alloy machine (ID=%s)\n' "${ID:-unknown}" >&2; exit 3; }
row PASS os "alloy ${VERSION_ID:-?} build ${IMAGE_VERSION:-unstamped}"

# ------------------------------------------------------------------- image ---
# `bootc status` is the row; parsing its JSON for the image name is a
# convenience, so an output shape this does not recognise degrades to a plainer
# detail rather than to a verdict.
# `bootc status` needs root: it opens the deployment for write before it will
# answer. Reported as SKIP without it rather than as a failure, which is what
# the first real run of this script got wrong on fw12 2026-09-03.
if ! have bootc; then
  row FAIL image "no bootc on an image-based system"
elif ! is_root; then
  row SKIP image "needs root: bootc status refuses an unprivileged caller"
else
  if bootc status >/dev/null 2>&1; then
    booted="$(bootc status --format=json 2>/dev/null \
      | tr -d ' \n' | grep -o '"image":"[^"]*"' | head -1 | cut -d'"' -f4)"
    row PASS image "${booted:-bootc answers; image name not parsed from this output shape}"
  else
    row FAIL image "bootc is present and \`bootc status\` fails"
  fi
fi

# ------------------------------------------------- labels, and what it costs --
# Delegated: build/check-installed.sh owns this question, including the
# semanage-lock filter and its own self-test. It needs root to read the labels.
if is_root && [ -x "$(dirname "$0")/check-installed.sh" ]; then
  if out="$("$(dirname "$0")/check-installed.sh" 2>&1)"; then
    row PASS labels "/etc and /usr match the policy, and a DynamicUser unit starts"
  else
    row FAIL labels "$(printf '%s' "$out" | head -3 | tr '\n' ' ') -- repair: restorecon -R /etc"
  fi
else
  row SKIP labels "needs root and build/check-installed.sh beside this script"
fi

# ------------------------------------------------------------- screenshots ---
# The four binds, from the config the compositor is actually running where
# there is one, and from the file otherwise. Pressing them is a person's job;
# that the bind exists and its command resolves is not.
if have alloy-shot; then
  if has_session; then
    binds="$(swaymsg -t get_config 2>/dev/null | grep -c 'exec alloy-shot')"
  else
    binds="$(grep -c 'exec alloy-shot' "$(target_home)/.config/sway/config" 2>/dev/null || echo 0)"
  fi
  if [ "${binds:-0}" -ge 4 ]; then
    row PASS shot-binds "$binds alloy-shot binds, and the binary resolves"
  else
    row FAIL shot-binds "expected 4 alloy-shot binds (output, region, window, annotate), found ${binds:-0}"
  fi
else
  row FAIL shot-binds "alloy-shot is not on PATH, so all four Print binds are dead"
fi

# -------------------------------------------------------------- brightness ---
# Two separate failures wear the same face. The bind can be missing, or the
# session can lack permission to write the backlight, and only the second one
# survives a reinstall unnoticed.
if have swayosd-client; then
  row PASS brightness-bind "swayosd-client resolves for the XF86MonBrightness binds"
else
  row FAIL brightness-bind "swayosd-client is not on PATH, so the brightness keys do nothing"
fi
bl="$(find /sys/class/backlight -mindepth 1 -maxdepth 1 2>/dev/null | head -1)"
if [ -z "$bl" ]; then
  row SKIP backlight "no backlight device on this machine"
elif [ -w "$bl/brightness" ]; then
  row PASS backlight "$(basename "$bl") is writable by this user"
else
  row FAIL backlight "$(basename "$bl")/brightness is not writable, so the keys cannot take effect"
fi

# -------------------------------------------------------------------- wifi ---
# Three states wear the same face on screen, and only one of them is fine.
# Measured on fw12 2026-09-03: the kernel had the interface and NetworkManager
# had no plugin to present it with, so `alloy net` showed loopback alone and
# nothing anywhere reported an error. No unit failed and the console was
# correct, which is why this is a row rather than something a user reports.
if nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep -q ':wifi$'; then
  seen="$(nmcli -t -f DEVICE,TYPE device 2>/dev/null | grep ':wifi$' | cut -d: -f1 | tr '\n' ' ')"
  row PASS wifi "NetworkManager presents ${seen%% }"
elif ! ls /usr/lib64/NetworkManager/*/libnm-device-plugin-wifi.so >/dev/null 2>&1; then
  row FAIL wifi "the image carries no NetworkManager wifi plugin, so no wireless device can ever appear"
elif ls -d /sys/class/net/*/wireless >/dev/null 2>&1; then
  row FAIL wifi "the kernel has a wireless interface and NetworkManager does not present it; check wpa_supplicant and rfkill"
else
  row SKIP wifi "no wireless interface on this machine"
fi

# ---------------------------------------------------------------- firewall ---
# The combination is the risk, not the firewall: tailscaled writes its own
# rules, and firewalld starting without tailscale0 in the trusted zone drops
# every inbound tailnet connection, which on a headless box is the login.
# systemctl answers unprivileged; `firewall-cmd --state` does not, and reading
# its refusal as "not running" reported a stopped firewall on a machine where
# firewalld was enabled and active (fw12, 2026-09-03).
if have firewall-cmd; then
  if systemctl is-active firewalld >/dev/null 2>&1; then
    if ! is_root; then
      row SKIP firewall "running; the trusted-zone check needs root"
    elif firewall-cmd --zone=trusted --query-interface=tailscale0 >/dev/null 2>&1; then
      row PASS firewall "running, tailscale0 in the trusted zone"
    else
      row FAIL firewall "running, and tailscale0 is NOT trusted; inbound tailnet traffic is being dropped"
    fi
  elif systemctl is-enabled firewalld >/dev/null 2>&1; then
    row FAIL firewall "firewalld is enabled and not running"
  else
    row FAIL firewall "firewalld is installed and neither enabled nor running"
  fi
else
  row SKIP firewall "no firewall-cmd in this image"
fi

# ------------------------------------------------------------ export wrapper --
# `alloy pkg` writes per-box wrappers under ~/.local/bin. A wrapper that is not
# on PATH is the failure that looks like nothing at all.
# The directory belongs to the user being checked; the PATH being searched
# belongs to whoever is running this. Under sudo those are different people, so
# the second half is not answerable and says so rather than reporting root's
# PATH as if it were the user's.
export_dir="$(target_home)/.local/bin"
if [ ! -d "$export_dir" ]; then
  row SKIP export-path "nothing has been exported on this machine yet"
elif is_root && [ -n "${SUDO_USER:-}" ]; then
  row SKIP export-path "$export_dir exists; whether it is on $(target_user)'s PATH cannot be read from a root shell"
else
  case ":$PATH:" in
    *":$export_dir:"*) row PASS export-path "$export_dir exists and is on PATH" ;;
    *) row FAIL export-path "$export_dir exists and is NOT on PATH, so every exported wrapper is invisible" ;;
  esac
fi

# ------------------------------------------------------------------- fonts ---
# Two halves: fontconfig resolves the face, and the face covers the glyphs the
# TUI draws with. A fallback that answers fc-match still draws broken tables.
if have fc-match; then
  fam="$(fc-match -f '%{family}' 'Quasi Mono' 2>/dev/null)"
  case "$fam" in
    *Quasi*) row PASS font-match "Quasi Mono resolves to $fam" ;;
    *) row FAIL font-match "Quasi Mono falls back to ${fam:-nothing}; the font layer did not take" ;;
  esac
  # U+2500, the box-drawing horizontal every table border is made of.
  if fc-list ':charset=2500' family 2>/dev/null | grep -qi quasi; then
    row PASS font-borders "the Quasi face covers U+2500, so table borders draw"
  else
    row FAIL font-borders "no Quasi face covers U+2500; borders will render from a fallback"
  fi
else
  row FAIL font-match "no fc-match, so nothing can resolve a font"
fi

# -------------------------------------------------------------------- luks ---
# Three keyslots is the shape the installer writes: the passphrase, the
# recovery key and the TPM binding. One means two of those are missing.
if ! is_root; then
  row SKIP luks "needs root to dump the header"
elif ! have cryptsetup; then
  row SKIP luks "no cryptsetup"
else
  dev="$(lsblk -rno NAME,FSTYPE | awk '$2=="crypto_LUKS"{print "/dev/"$1; exit}')"
  if [ -z "$dev" ]; then
    row SKIP luks "no LUKS device; this machine was installed unencrypted"
  else
    dump="$(cryptsetup luksDump "$dev" 2>/dev/null)"
    slots="$(printf '%s' "$dump" | grep -cE '^[[:space:]]+[0-9]+: luks2')"
    if [ -z "$dump" ]; then
      row SKIP luks "$dev did not dump; header unreadable"
    elif [ "${slots:-0}" -eq 0 ]; then
      # A dump with no recognised slot line is a format this does not read, not
      # a device with no keyslots -- which cannot exist and would unlock nothing.
      row SKIP luks "$dev dumped, no keyslot line recognised (LUKS1, or a changed format)"
    elif [ "$slots" -ge 3 ]; then
      row PASS luks "$dev has $slots keyslots"
    else
      row FAIL luks "$dev has $slots keyslots, expected 3 (passphrase, recovery, TPM)"
    fi
  fi
fi

# ------------------------------------------------------------------ udisks ---
# Whether a session user can mount a stick without a polkit rule of ours. The
# answer is a property of the shipped policy, so it is readable without root.
if have pkaction; then
  # The label is polkit's own wording; an unrecognised one leaves impl empty
  # and the row skips, because guessing here would report a prompt that is not
  # there or miss one that is.
  impl="$(pkaction --action-id org.freedesktop.udisks2.filesystem-mount --verbose 2>/dev/null \
          | awk -F: '/implicit active/{gsub(/[[:space:]]/,"",$2); print $2; exit}')"
  case "$impl" in
    yes) row PASS udisks "filesystem-mount is allowed for an active session" ;;
    "")  row SKIP udisks "udisks2 policy not present" ;;
    *)   row FAIL udisks "filesystem-mount implicit active is '$impl', so mounting a stick prompts" ;;
  esac
else
  row SKIP udisks "no pkaction"
fi

# -------------------------------------------------------------------- mdns ---
# Publishing a .local name and resolving one are different capabilities, and
# only the second makes `ssh max.local` work from this machine.
if have resolvectl; then
  if resolvectl query "$(hostname).local" >/dev/null 2>&1; then
    row PASS mdns "this machine resolves its own .local name"
  else
    row FAIL mdns "cannot resolve $(hostname).local; mDNS resolution is off even if publishing works"
  fi
else
  row SKIP mdns "no resolvectl"
fi

# ------------------------------------------------------------------ portal ---
# The portal is what tells a GTK or Electron client which way the theme went.
if has_session && have gdbus; then
  if gdbus call --session --dest org.freedesktop.portal.Desktop \
      --object-path /org/freedesktop/portal/desktop \
      --method org.freedesktop.portal.Settings.ReadOne \
      org.freedesktop.appearance color-scheme >/dev/null 2>&1; then
    row PASS portal-theme "the portal answers color-scheme"
  else
    row FAIL portal-theme "the portal does not answer color-scheme, so clients will not follow the theme"
  fi
else
  row SKIP portal-theme "needs a graphical session; not answerable over ssh"
fi

# ------------------------------------------------------------------ linger ---
# The silent-loss class. On a machine whose evidence tier runs as user timers,
# a missing linger produces a healthy-looking box where nothing ever runs.
if have loginctl; then
  user="${SUDO_USER:-$(id -un)}"
  if loginctl show-user "$user" -p Linger 2>/dev/null | grep -q 'Linger=yes'; then
    row PASS linger "$user has Linger=yes, so user timers run without a session"
  else
    row SKIP linger "$user has no linger; only a finding on a machine with user timers"
  fi
else
  row SKIP linger "no loginctl"
fi

# ------------------------------------------------------------------ nvidia ---
if lspci 2>/dev/null | grep -qi 'nvidia'; then
  if have nvidia-smi && nvidia-smi -L >/dev/null 2>&1; then
    row PASS nvidia "$(nvidia-smi -L 2>/dev/null | head -1)"
  else
    row FAIL nvidia "an NVIDIA card is on the bus and the driver does not answer"
  fi
else
  row SKIP nvidia "no NVIDIA card on this machine"
fi

# ---------------------------------------------------------------- toolchain ---
# The silent class, like linger above: nothing fails, and the machine builds
# every release on a compiler the tree did not ask for.
#
# `rust-toolchain.toml` is honoured by rustup's proxy and by nothing else, so a
# build host whose $HOME has no rustup runs Fedora's rust and says so nowhere.
# Measured 2026-09-04 in a container from the fw13 image: rustc 1.98.0 against a
# tree pinning 1.97.1, `cargo test` green, not one word of warning. The cost is
# not a broken build but a quiet one -- rustfmt output moves between compiler
# versions, which is the whole reason the pins exist, and the sweep's fmt cell
# then measures which machine formatted last.
#
# Two of the three publish gates are louder than that, and only by accident:
# Fedora's rust package carries neither rustfmt nor clippy, so on the fw13 image
# `cargo fmt` and `cargo clippy` are `no such command` rather than the wrong
# version. Measured in the rehearsal-4 run. That makes the publish recipe fail at
# its first gate on a rustup-less host, which is the good outcome; `cargo test`
# and `cargo publish --dry-run` both ran green there on 1.98.0, which is the bad
# one. So the components are asked for separately below: rustup being installed
# and on the right channel does not mean the toolchain carries them, and a
# `--profile minimal` install without them fails a release rather than a build.
#
# Only a build host is asked. A laptop with no tree is not failing anything by
# not having a toolchain, so the absence of either the tree or rustc is a SKIP.
pin_file="$(target_home)/Code/alloy/rust-toolchain.toml"
if [ ! -r "$pin_file" ]; then
  row SKIP toolchain "no ~/Code/alloy/rust-toolchain.toml; not a build host"
elif ! have rustc; then
  row SKIP toolchain "no rustc for $(target_user); not a build host"
else
  # The pin as written, and the active compiler's own version. Both are parsed
  # narrowly on purpose: a shape this does not recognise degrades to SKIP, since
  # a battery that invents a FAIL is one people learn to ignore.
  pinned="$(sed -n 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$pin_file" | head -1)"
  active="$(rustc --version 2>/dev/null | awk '{print $2}')"
  if [ -z "$pinned" ] || [ -z "$active" ]; then
    row SKIP toolchain "could not read the pin or the active version from this output shape"
  elif [ "$pinned" = "$active" ]; then
    # The channel is right. Now the two components the publish recipe runs
    # before anything else, each asked for by the command that would fail.
    missing=""
    cargo fmt --version >/dev/null 2>&1 || missing="rustfmt"
    cargo clippy --version >/dev/null 2>&1 || missing="${missing:+$missing }clippy"
    if [ -n "$missing" ]; then
      row FAIL toolchain "rustc $active matches the pin but $missing is absent -- run: rustup component add $missing"
    else
      row PASS toolchain "rustc $active matches the pinned channel, with rustfmt and clippy"
    fi
  elif have rustup; then
    row FAIL toolchain "rustc $active, tree pins $pinned, and rustup is installed -- run: rustup toolchain install $pinned"
  else
    row FAIL toolchain "rustc $active, tree pins $pinned, and there is no rustup to honour it -- the pin is being ignored silently"
  fi
fi

# ------------------------------------------------------------------ verdict ---
printf '\n%s passed, %s failed, %s skipped\n' "$PASSED" "$FAILED" "$SKIPPED"
[ "$SKIPPED" -gt 0 ] && printf 'skipped rows are unanswered questions, not passes\n'
[ "$FAILED" -eq 0 ] || exit 1
exit 0
