The audit's third tier: everything between this installer and a fresh machine it has never met. The one path that could cost a person their display: the interview probes Secure Boot with mokutil, which install-packages had not installed yet, so on a minimal base the MOK question silently never fired -- and install-hardware still installed akmod-nvidia and blacklisted nouveau, arming a reboot into an unloadable driver with its fallback disabled. The probe tools (pciutils, mokutil, fwupd) now bootstrap beside gum, and install-hardware re-checks Secure Boot for itself and refuses the driver rather than the display. Secrets leave the checkout: the personal environment moves to ~/.config/panama/env at mode 600 by migration, and .bashrc sources it with a permission check that quietly re-tightens drift. change-settings no longer overwrites /etc/dnf/dnf.conf -- two performance keys are set additively, the defaultyes=True that made every `dnf remove` treat Enter as yes is gone, and a migration strips it from machines that already received it. Package installation survives the world changing: the initial and desktop lists run with --skip-unavailable and a report_missing pass that names what was skipped (resolved through --whatprovides, so capability names like awk do not cry wolf); the openh264, appstream and core-group extras go through soft; RustDesk resolves its RPM for the machine's own architecture; and the Claude Desktop repository script is fetched to a kept file and run, never piped from the network into root. The hardware predicates stop guessing: a wireless mouse's scope=Device battery no longer turns a tower into a laptop, USB-PD-only machines read their power state from the battery's own status instead of being permanently "on AC", the lid falls back to logind's LidClosed where ACPI is silent, and charge limits reach every pack of a two-battery machine in one authorization -- with the reported percentage summed across packs. And the parsers stop assuming this machine: snapper is read through --machine-readable csv with named columns instead of a localized box-drawing table, and reports whether snapshots are even possible so ext4 and unconfigured-btrfs stop looking identical; fprintd is parsed under LC_ALL=C; the hypridle drop-in resolves the binary it points at; the recorder's render node became an "auto" token resolved at record time; update-grub writes the config its firmware actually boots; the nvm prompt hook and the SSH tmux takeover are guarded; hipblas and rocm-opencl move to an opt-in gpu-compute category; and the two interactive python tools' libraries are declared. Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
236 lines
12 KiB
Bash
Executable File
236 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# Every external command Panama's own scripts invoke must be installed by
|
|
# Panama's own package lists.
|
|
#
|
|
# This exists because the lists had drifted badly. jq is used by thirty-one call
|
|
# sites across the helpers and the contracts; kitty has a full shipped config
|
|
# and a dock pin; tmux and btop have shipped themes that the color scheme
|
|
# switches. None of the four were declared. So a fresh machine that followed
|
|
# this repository's own install instructions would not have them.
|
|
#
|
|
# The failure is quiet by design, which is what makes it worth a test: the
|
|
# helpers are written to report "not installed" rather than crash, so a missing
|
|
# dependency presents as a feature that silently is not there.
|
|
#
|
|
# Commands from coreutils and the shell itself are not checked -- nothing
|
|
# installs those separately, and listing them would be noise.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
|
|
fail() {
|
|
printf 'declared dependencies contract: %s\n' "$1" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Shell syntax and builtins. These are not commands anyone installs, and the
|
|
# first version of this contract reported `then`, `esac` and `done` as missing
|
|
# packages, which buried the four real findings in a hundred lines of noise.
|
|
SHELL_WORDS='^(if|then|else|elif|fi|for|while|until|do|done|case|esac|in|function|select|time|coproc|break|continue|return|exit|local|readonly|declare|export|unset|shift|eval|exec|source|trap|set|shopt|alias|unalias|builtin|command|enable|help|let|read|mapfile|printf|echo|test|true|false|wait|jobs|bg|fg|kill|pwd|cd|dirs|pushd|popd|umask|type|hash|getopts|split|sync)$'
|
|
|
|
# Provided by any Fedora install: coreutils, util-linux, the shell, and the
|
|
# systemd/session tooling. Nothing here is a choice Panama makes.
|
|
#
|
|
# authselect is on the list for the same reason: it manages Fedora's PAM and
|
|
# nsswitch profiles and arrives with fprintd-pam, realmd and nss-mdns, so the
|
|
# fingerprint aliases in config/bash can rely on it without declaring it.
|
|
BASELINE='^(sh|bash|cat|cut|sed|awk|gawk|grep|egrep|head|tail|sort|uniq|tr|wc|find|xargs|basename|dirname|mkdir|rm|cp|mv|ln|chmod|chown|stat|df|du|date|sleep|env|id|tee|touch|mktemp|readlink|realpath|seq|comm|join|paste|od|file|nl|fold|column|tput|timeout|flock|install|sha256sum|md5sum|base64|nproc|uptime|free|uname|hostname|whoami|ps|pgrep|pkill|kill|killall|lsblk|mount|umount|sudo|su|rpm|dnf|flatpak|git|python3|ss|ip|lsof|authselect|setsid|nohup|grub2-mkconfig)$'
|
|
|
|
SESSION='^(systemctl|busctl|journalctl|loginctl|hostnamectl|localectl|systemd-inhibit|systemd-run|udevadm|gsettings|dconf|dbus-send|dbus-monitor|hyprctl|qs|quickshell|gnf|panama|wl-copy|wl-paste)$'
|
|
|
|
# Installed by install-packages itself rather than by a package list. Two
|
|
# reasons, both deliberate: bun and claude have no RPM or flatpak at all, and
|
|
# node, npm and pnpm come from nvm on purpose -- a dnf nodejs earlier on PATH
|
|
# would win every per-project `nvm use`, which is the whole point of having nvm.
|
|
# Anything added here needs a matching install block and a stated reason.
|
|
SELF_INSTALLED='^(bun|claude|node|npm|pnpm)$'
|
|
|
|
# Tools an alias may lean on without Panama installing them anywhere. The
|
|
# docker aliases serve the machines that run Docker by deliberate choice;
|
|
# Panama's container runtime is rootless podman (development-packages), and
|
|
# declaring docker in a list would put a second container daemon on every
|
|
# fresh machine to keep five aliases company. Where docker is absent the
|
|
# aliases fail by naming the missing command, which is the honest outcome.
|
|
# Anything added here needs that same property: absence must be loud.
|
|
OPTIONAL='^(docker)$'
|
|
|
|
# jq programs are quoted arguments, but the scanner is line-based and cannot
|
|
# tell a filter from a command. `not` is a jq builtin appearing inside one.
|
|
JQ_BUILTINS='^(not|empty|error|env|input|inputs)$'
|
|
|
|
declared="$(cat "$repo_dir"/setup/packages/* 2>/dev/null | sed 's/#.*//' | tr -d ' ' | grep -v '^$' | sort -u)"
|
|
[[ -n "$declared" ]] || fail 'no package lists found'
|
|
|
|
# A package is not always named after its command. Only the genuine mismatches
|
|
# are mapped, so an unmapped command is a real omission rather than a lookup
|
|
# failure.
|
|
package_for() {
|
|
case "$1" in
|
|
zbarimg) printf 'zbar' ;;
|
|
fc-list|fc-match) printf 'fontconfig' ;;
|
|
fprintd-list) printf 'fprintd' ;;
|
|
lspci) printf 'pciutils' ;;
|
|
getenforce) printf 'libselinux-utils' ;;
|
|
nmcli) printf 'NetworkManager' ;;
|
|
wpctl) printf 'wireplumber' ;;
|
|
nvim) printf 'neovim' ;;
|
|
fwupdmgr) printf 'fwupd' ;;
|
|
dnf4) printf 'python3-dnf' ;;
|
|
notify-send) printf 'libnotify' ;;
|
|
wl-copy|wl-paste) printf 'wl-clipboard' ;;
|
|
ssh-keygen) printf 'openssh' ;;
|
|
ssh|ssh-add) printf 'openssh-clients' ;;
|
|
# The Wayland build is the one that can inject into this session; the
|
|
# x11 one cannot. desktop-packages declares it under that name.
|
|
espanso) printf 'espanso-wayland' ;;
|
|
rg) printf 'ripgrep' ;;
|
|
xdg-mime|xdg-settings|xdg-open) printf 'xdg-utils' ;;
|
|
update-desktop-database|desktop-file-validate) printf 'desktop-file-utils' ;;
|
|
python3) printf 'python3' ;;
|
|
*) printf '%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
missing=()
|
|
unguarded=()
|
|
checked=0
|
|
|
|
while read -r script; do
|
|
[[ -n "$script" ]] || continue
|
|
head -1 "$script" | grep -qE 'bash|/sh' || continue
|
|
|
|
# Commands appearing at the start of a statement or after a pipe. Crude, but
|
|
# it is looking for undeclared dependencies, not building a call graph.
|
|
#
|
|
# No minimum length. An earlier version required three characters, which
|
|
# quietly excluded the most-used dependency in the repository -- jq, at
|
|
# thirty-one call sites -- along with rg, ss and ip. A dependency checker
|
|
# with a blind spot for short names is worse than none, because it reports
|
|
# PASS.
|
|
while read -r cmd; do
|
|
[[ -n "$cmd" ]] || continue
|
|
[[ "$cmd" =~ $SHELL_WORDS ]] && continue
|
|
[[ "$cmd" =~ $BASELINE ]] && continue
|
|
[[ "$cmd" =~ $SESSION ]] && continue
|
|
[[ "$cmd" =~ $SELF_INSTALLED ]] && continue
|
|
[[ "$cmd" =~ $OPTIONAL ]] && continue
|
|
[[ "$cmd" =~ $JQ_BUILTINS ]] && continue
|
|
|
|
pkg="$(package_for "$cmd")"
|
|
grep -qx "$pkg" <<<"$declared" && continue
|
|
|
|
# Only report a command that actually exists on this machine. An
|
|
# invented name in a comment or a heredoc is a false positive; a real
|
|
# binary that nothing declares is the thing being looked for.
|
|
command -v "$cmd" >/dev/null 2>&1 || continue
|
|
|
|
missing+=("$cmd (from $(basename "$script"), package: $pkg)")
|
|
done < <({
|
|
# Heredoc bodies are not shell. A python or sql block embedded in a
|
|
# script is scanned as though every line began a command, and its
|
|
# keywords collide with real binaries often enough to matter: `import`
|
|
# is ImageMagick, `time` is a package, `select` is shell syntax. The
|
|
# scanner cannot parse those languages and should not try, so the
|
|
# bodies are dropped before anything else looks at them.
|
|
scanned="$(awk '
|
|
# Whole-line comments. These files explain themselves at length,
|
|
# and prose containing "; cancel it" or "| list" reads as a
|
|
# statement to a line-based scanner. Dropped first so a comment
|
|
# mentioning <<EOF cannot open a heredoc either.
|
|
!inbody && /^[[:space:]]*#/ { next }
|
|
|
|
# <<MARKER, <<-MARKER, <<"MARKER", <<'"'"'MARKER'"'"' -- with or
|
|
# without a command in front of it.
|
|
!inbody && match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z0-9_]*["'"'"']?/) {
|
|
marker = substr($0, RSTART, RLENGTH)
|
|
gsub(/^<<-?[[:space:]]*["'"'"']?|["'"'"']?$/, "", marker)
|
|
inbody = 1
|
|
print
|
|
next
|
|
}
|
|
inbody {
|
|
line = $0
|
|
gsub(/^[[:space:]]+|[[:space:]]+$/, "", line)
|
|
if (line == marker) inbody = 0
|
|
next
|
|
}
|
|
{ print }
|
|
' "$script")"
|
|
|
|
# Statement-initial or after a pipe.
|
|
#
|
|
# A word followed by `=` is a variable assignment and a word followed
|
|
# by `)` is a case label; neither is a command, and both collide with
|
|
# real binaries -- `count`, `host` and `cancel` are all installed on a
|
|
# normal Fedora machine, so `command -v` below cannot filter them out.
|
|
# Requiring whitespace or end-of-line after the word excludes both.
|
|
grep -oE '(^|[|;&]|\$\()[[:space:]]*[a-z][a-z0-9_-]+([[:space:]]|$)' <<<"$scanned" \
|
|
| grep -oE '[a-z][a-z0-9_-]+'
|
|
|
|
# Behind a wrapper. ddcutil is always invoked as `timeout 10 ddcutil`,
|
|
# so it never appears statement-initial and was missed entirely.
|
|
grep -oE '\b(timeout[[:space:]]+[0-9.]+|sudo|nohup|env)[[:space:]]+[a-z][a-z0-9_-]+' <<<"$scanned" \
|
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
|
|
|
# `command -v X` is how these helpers probe for a tool before using it,
|
|
# which makes it the clearest possible statement of a dependency.
|
|
grep -oE 'command -v[[:space:]]+[a-z][a-z0-9_-]+' "$script" \
|
|
| grep -oE '[a-z][a-z0-9_-]+$'
|
|
} | sort -u)
|
|
|
|
checked=$((checked + 1))
|
|
done < <(find "$repo_dir/config/dot/quickshell/scripts" \
|
|
"$repo_dir/config/local/share/vicinae/scripts" \
|
|
"$repo_dir/setup/scripts" "$repo_dir/bin" \
|
|
"$repo_dir/config/bash" \
|
|
-type f 2>/dev/null)
|
|
|
|
# ── Sourced paths ────────────────────────────────────────────────────────────
|
|
#
|
|
# config/bash is scanned above because it is where dependencies hide: the shell
|
|
# configuration names nvm, oh-my-posh, zoxide, eza and fzf, and it was excluded
|
|
# for long enough that `nvm` reached this repository's own shell config without
|
|
# ever being installed by it.
|
|
#
|
|
# Sourcing is the other half. A shell configuration that sources a path nothing
|
|
# guarantees exists produces an error on every single shell start, on exactly
|
|
# the machines least able to explain it -- new ones. That happened twice here:
|
|
# `$HOME/.cargo/env`, which rustup writes only after rustup-init has run, and
|
|
# `/etc/profile.d/nvm.sh`, which belongs to a package nothing installed.
|
|
#
|
|
# So a source of anything outside this repository has to be guarded. Whether the
|
|
# owning package is declared is not enough: the file still does not exist until
|
|
# that package is installed, and a shell can be opened before then.
|
|
while read -r file; do
|
|
[[ -f "$file" ]] || continue
|
|
while IFS= read -r line; do
|
|
# Only unconditional ones. A guard anywhere on the line is the fix.
|
|
[[ "$line" =~ \[\[?[[:space:]]*-[fesr] ]] && continue
|
|
path="$(sed -E 's/^[[:space:]]*(source|\.)[[:space:]]+//; s/[[:space:]].*//' <<<"$line")"
|
|
[[ -n "$path" ]] || continue
|
|
# Only literal paths. `. "$rc"` inside a loop that already tested the
|
|
# variable is a different shape, guarded structurally rather than on the
|
|
# same line, and reporting it would bury the real findings.
|
|
[[ "$path" =~ ^[\"\']?(/|~|\$HOME|\$\{HOME) ]] || continue
|
|
# Paths inside the repository ship with it and are always present.
|
|
[[ "$path" == *PANAMA* ]] && continue
|
|
unguarded+=("$(basename "$file"): $path")
|
|
done < <(grep -nE '^[[:space:]]*(source|\.)[[:space:]]+[^[:space:]]' "$file" | sed 's/^[0-9]*://')
|
|
done < <(find "$repo_dir/config/bash" -type f 2>/dev/null)
|
|
|
|
if (( ${#unguarded[@]} > 0 )); then
|
|
printf 'declared dependencies contract: sourced without checking it exists:\n' >&2
|
|
printf ' %s\n' "${unguarded[@]}" | sort -u >&2
|
|
fail 'guard each with a -f test, or every shell on a fresh machine starts with an error'
|
|
fi
|
|
|
|
if (( ${#missing[@]} > 0 )); then
|
|
printf 'declared dependencies contract: commands used but never installed:\n' >&2
|
|
printf ' %s\n' "${missing[@]}" | sort -u >&2
|
|
fail 'add each to a list in setup/packages/, or the feature silently will not exist on a fresh machine'
|
|
fi
|
|
|
|
printf 'declared dependencies contract: PASS (%d scripts)\n' "$checked"
|