#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
# Copyright (C) 2026 Jasper Nuyens <jnuyens@linuxbe.com>
# ModuleJail: shrink a Linux host's kernel-module attack surface.
set -eu

# Force C locale for the whole pipeline. comm -23 requires its inputs to be
# sorted byte-identically; under a non-C locale (e.g. en_US.UTF-8 or
# tr_TR.UTF-8) sort uses locale-aware collation, which can silently desync
# the universe/keep ordering and produce wrong set arithmetic. Also
# stabilises awk regex matching.
LC_ALL=C
export LC_ALL

# Ensure /usr/sbin and /sbin are in PATH. Several initramfs builders we
# detect for --install-initramfs-hook live there (update-initramfs is in
# /usr/sbin on Debian, dracut is in /usr/sbin on RHEL/Rocky), and
# non-root login PATHs on those distros frequently omit those
# directories. Without this, `command -v update-initramfs` returns
# nothing for a non-root caller running `--install-initramfs-hook
# --dry-run`, and detection silently fails. Idempotent for root.
case ":$PATH:" in
    *:/usr/sbin:*) ;;
    *) PATH="$PATH:/usr/sbin" ;;
esac
case ":$PATH:" in
    *:/sbin:*) ;;
    *) PATH="$PATH:/sbin" ;;
esac
export PATH

# --- Tool version + sysexits.h exit code constants ---
# VERSION is the single source of truth for both --version and the
# generated blacklist header. Bump on every release per SemVer.
VERSION='1.4.2'
EX_OK=0
# sysexits.h conventional codes (see man 3 sysexits):
#   64=usage 65=dataerr 66=noinput 70=software 71=oserr 73=cantcreat 77=noperm
EX_USAGE=64
EX_DATAERR=65
EX_NOINPUT=66
EX_SOFTWARE=70
EX_OSERR=71
EX_CANTCREAT=73
EX_NOPERM=77

# --- Defaults (set before arg parsing) ---
profile=conservative
output=/etc/modprobe.d/modulejail-blacklist.conf
WHITELIST_FILE=''
NO_WHITELIST_FILE=''
NO_SYSLOG_LOGGING=''
USE_LOGGER=''
FAIL_ON_MODULE_LOAD=0
VERBOSE_LOGGING=0
DRY_RUN=0
QUIET=0
VERBOSE=0
OUTPUT_FORMAT=''
INITRAMFS_HOOK_ACTION=''
ASSUME_YES=0
SELF_UPDATE=0

# Path checked for a site-local whitelist when --whitelist-file is not given.
# When the file exists, it is loaded automatically with the same strict
# validation as --whitelist-file. When absent, no error (mirrors v1.1.4
# behavior for hosts that don't ship one). Operators opt out with
# --no-whitelist-file or by removing the file.
#
# Overridable via MODULEJAIL_DEFAULT_WHITELIST_FILE for test fixtures (test-only
# plumbing, cf. MODULEJAIL_PROC_MODULES / MODULEJAIL_KVER / MODULEJAIL_LOGGER_PATH).
DEFAULT_WHITELIST_FILE="${MODULEJAIL_DEFAULT_WHITELIST_FILE:-/etc/modulejail/whitelist.conf}"

# --- Built-in baseline module lists (space-separated strings, not bash arrays) ---
# Each profile inherits from the one above via variable interpolation (DRY).
# Module names are in canonical underscore form; the pipeline normalizes both forms.

# BASELINE_MINIMAL (16 entries):
# Core filesystems, NLS deps for vfat/ESP, loop/overlay/autofs, crc32c/crc16.
# Intent: "Won't break a box that's already booted" — safety net for hosts
# where most drivers are compiled in but a few modular essentials remain.
BASELINE_MINIMAL='ext4 xfs btrfs vfat fat nls_cp437 nls_iso8859_1 nls_utf8 tmpfs loop overlay autofs4 crc32c_generic crc32c_intel libcrc32c crc16'

# BASELINE_CONSERVATIVE (MINIMAL + 44 entries ≈ 60 total):
# Virtio/KVM, common storage controllers, common ethernet NICs, HID/input,
# dm-crypt/LVM, crypto primitives, socket diagnostics (ss/system-monitor),
# CPU governor, kernel TLS. Default profile.
# Intent: "Bare-metal or VM Linux server across the mainstream hardware matrix."
BASELINE_CONSERVATIVE="$BASELINE_MINIMAL virtio virtio_ring virtio_pci virtio_blk virtio_net virtio_scsi virtio_balloon virtio_rng nvme nvme_core ahci libahci libata sd_mod sr_mod scsi_mod usb_storage uas e1000 e1000e igb igc ixgbe r8169 tg3 bnxt_en bnx2 hid hid_generic usbhid i8042 dm_mod dm_crypt dm_mirror aes_generic aesni_intel sha256_generic xts cbc inet_diag tcp_diag udp_diag acpi_cpufreq tls"

# BASELINE_DESKTOP (CONSERVATIVE + ~65 entries ≈ 125 total):
# Graphics/DRM, WiFi, Bluetooth, audio (ALSA/HDA), USB host controllers,
# misc desktop peripherals, webcam/video4linux, removable-media
# filesystems (exfat/f2fs/ntfs3/isofs), CPU EDAC, CPU power management
# (intel_pstate/intel_cstate/amd_pstate), and TUN/TAP for VPN clients
# and VMs.
# Intent: "Laptop or workstation — won't break WiFi, BT, audio, video,
# CPU governors, or VPN/VM session setup."
# shellcheck disable=SC2034  # referenced indirectly via the profile case statement
BASELINE_DESKTOP="$BASELINE_CONSERVATIVE drm drm_kms_helper i915 amdgpu radeon nouveau nvidia ttm video backlight cfg80211 mac80211 iwlwifi iwlmvm iwldvm ath9k ath10k_core ath10k_pci ath11k brcmfmac brcmsmac rtw88_core rtw89_core rtl8xxxu bluetooth btusb btintel btrtl btbcm hci_uart snd snd_pcm snd_timer snd_hda_intel snd_hda_codec snd_hda_codec_realtek snd_hda_codec_hdmi snd_hda_codec_generic snd_usb_audio snd_seq xhci_hcd xhci_pci ehci_hcd ehci_pci uhci_hcd usblp cdc_acm joydev evdev videodev uvcvideo media exfat f2fs ntfs3 isofs cdrom mmc_core mmc_block rpmb_core amd64_edac i7core_edac ie31200_edac intel_pstate intel_cstate amd_pstate tun tap"

# --- Baseline-addition policy (in effect since v1.3.4) ---
#
# Modules join a baseline only when there is observed operator pain in
# that profile's target audience.
#   CONSERVATIVE target = bare-metal/VM Linux servers (hands-on admins,
#                         post-steady-state runs).
#   DESKTOP target      = laptops/workstations (set-and-forget UX,
#                         modulejail may run at any time including
#                         before all udev/late-load events have settled).
# "Defensive add because the kernel sometimes loads it late" is
# insufficient justification - a real operator-reported breakage in
# the relevant profile's target audience is the bar. `acpi_cpufreq`
# in CONSERVATIVE (added v1.3.2) predates this policy and is retained
# for backward compatibility; it would not pass the policy as worded.
#
# Deliberately NOT in any baseline (= blacklisted by default on hosts
# where they are not currently loaded). These categories have been the
# source of most unprivileged-user → root LPE chains in recent Linux
# kernel CVEs, and operators who genuinely need them are expected to add
# the specific names to the WHITELIST= below.
#
# Network filesystems (CIFS / NFS / Ceph / GlusterFS / 9P):
#   cifs, nfs, nfsv3, nfsv4, ceph, fuse, 9p
#   Reachable via mount(2) with the matching fstype. CIFS in particular
#   carries cifs.upcall trust chains (see CIFSwitch, May 2026) that
#   chain to root via request_key("cifs.spnego", ...) - which fails
#   with -ENOKEY when cifs.ko is not loaded. NFS/Ceph/GlusterFS get
#   the same treatment: keep them out of the keep-set on hosts that
#   are not actively mounting those filesystems.
#
# Legacy / niche socket families (PF_X25, PF_AX25, PF_DECNET, PF_IPX,
# PF_APPLETALK, PF_NETROM, PF_ROSE, PF_LLC, AF_RDS, AF_TIPC, AF_NFC,
# AF_VSOCK, AF_CAN, AF_QIPCRTR, AF_SMC):
#   sctp, dccp, tipc, rds, nfc, vsock, can, qrtr, smc, x25, ax25,
#   decnet, ipx, appletalk, netrom, rose, llc2
#   Reachable via socket(AF_X, ...) by any unprivileged user. Many
#   distros already ship `blacklist net-pf-N` for the worst legacy
#   ones; ModuleJail extends that to the whole class on hosts that
#   are not using them.
#
# Crypto algorithm glue (AF_ALG handlers):
#   algif_aead, algif_skcipher, algif_hash, algif_rng
#   The Copy Fail CVE (CVE-2026-31431) is exactly this class. The
#   primitive ciphers in BASELINE_CONSERVATIVE (aes_generic, xts, cbc)
#   are kept because dm-crypt / WireGuard / kTLS use them; the algif_*
#   glue is not used by anything on a typical server.
#
# Exotic crypto algorithms:
#   aria, aegis128, chacha20poly1305 (standalone), sm4, streebog,
#   serpent, twofish, camellia, cast5, cast6, tea, tgr192, wp512
#   Reachable via the same AF_ALG path; less load-bearing than the
#   AES family.
#
# Misc kernel-attack-surface modules an operator is unlikely to need:
#   rxe (Soft-RoCE: CVE-2026-46133), binfmt_aout, binfmt_em86,
#   binfmt_flat, nf_tables sub-modules a non-firewall host does not
#   reference.
#
# See docs/DEFENSE-IN-DEPTH.md for the full 7-tier autoload taxonomy
# and the threat-model framing. Operators who genuinely need any of
# the above should add the specific module names to the WHITELIST=
# variable below.

# === SYSADMIN WHITELIST ===
# Site-local additions to the keep-set, in addition to the selected baseline
# profile. Modules listed here will never appear in the generated blacklist.
#
# Format: space-separated module names in canonical underscore form
#         (the pipeline normalizes - to _, so either form works).
# Default: empty.
#
# Example (uncomment and adapt — `nft_compat` is the iptables-nft
# compatibility shim, a plausible netfilter module a sysadmin running
# iptables on a modern host might want to keep beyond the baseline):
# WHITELIST='nft_compat xt_owner'
WHITELIST=''
# === END SYSADMIN WHITELIST ===

# --- usage(): print help text; caller decides exit code ---
usage() {
    printf 'modulejail — shrink a Linux host'\''s kernel-module attack surface\n'
    printf '\n'
    printf 'Usage: modulejail [OPTIONS]\n'
    printf '\n'
    printf 'Options:\n'
    printf '  -p, --profile {minimal|conservative|desktop|none}\n'
    printf '                    Built-in baseline profile (default: conservative)\n'
    printf '  -o, --output PATH Output path for the generated blacklist file\n'
    printf '                    (default: /etc/modprobe.d/modulejail-blacklist.conf)\n'
    printf '  --whitelist-file PATH\n'
    printf '                    Append module names from PATH to the keep-set.\n'
    printf '                    One module per line; '\''#'\'' starts a comment.\n'
    printf '                    File must not be group- or world-writable.\n'
    printf '                    Default (auto-detected if present and no flag passed):\n'
    printf '                    /etc/modulejail/whitelist.conf\n'
    printf '  --no-whitelist-file\n'
    printf '                    Skip the default whitelist file even if present.\n'
    printf '                    Mutually exclusive with --whitelist-file PATH.\n'
    printf '  --no-syslog-logging\n'
    printf '                    Force '\''/bin/true'\'' install lines (v1.1.4 behavior).\n'
    printf '                    By default, when /usr/bin/logger is present, blocked\n'
    printf '                    module loads are logged to syslog with tag '\''modulejail'\''.\n'
    printf '  -f, --fail-on-module-load\n'
    printf '                    Blocked module loads return a non-zero exit code\n'
    printf '                    (modprobe fails loudly). Default: blocked loads\n'
    printf '                    silently succeed (modprobe returns 0).\n'
    # shellcheck disable=SC2016  # $PPID is intentional literal in --help docs
    printf '  --verbose-logging Enrich the syslog "blocked" line with the\n'
    # shellcheck disable=SC2016
    printf '                    caller'\''s PPID, loginuid, parent comm, and\n'
    # shellcheck disable=SC2016
    printf '                    argv[0] (read from /proc/$PPID/...). Requires\n'
    printf '                    /usr/bin/logger (mutually exclusive with\n'
    printf '                    --no-syslog-logging).\n'
    printf '  --dry-run         Compute the would-be blacklist and print a summary to\n'
    printf '                    stdout; do NOT write the output file or touch\n'
    printf '                    /etc/modprobe.d/. Header is rerouted to stderr.\n'
    printf '  --quiet           Suppress all non-error stderr (info lines, summary,\n'
    printf '                    header). Mutually exclusive with --verbose.\n'
    printf '  --verbose         Emit per-module decision lines on stderr. Mutually\n'
    printf '                    exclusive with --quiet.\n'
    printf '  --output-format {json|logfmt}\n'
    printf '                    Emit a machine-readable run summary to stdout instead\n'
    printf '                    of the default human-readable form. JSON round-trips\n'
    printf '                    through jq; logfmt round-trips through standard\n'
    printf '                    logfmt parsers. Survives --quiet.\n'
    printf '  --install-initramfs-hook\n'
    printf '                    Detect the installed initramfs builder (dracut /\n'
    printf '                    initramfs-tools / mkinitcpio) and install a small\n'
    printf '                    hook that strips modulejail-blacklist.conf from\n'
    printf '                    rebuilt initramfs cpios. Closes the upgrade-then-\n'
    printf '                    stale class of bug (gh #19). Prints the operator-\n'
    printf '                    runnable rebuild command after writing; does NOT\n'
    printf '                    rebuild the initramfs (operator schedules that on\n'
    printf '                    their own time). Requires root. Pair with --dry-run\n'
    printf '                    to print what would be written without touching\n'
    printf '                    any file.\n'
    printf '  --uninstall-initramfs-hook\n'
    printf '                    Remove all four possible hook file paths regardless\n'
    printf '                    of which builder is currently detected (handles\n'
    printf '                    distro migrations). Requires root.\n'
    printf '  -y, --yes         Skip the interactive confirmation prompt that\n'
    printf '                    --self-update would otherwise present. Required\n'
    printf '                    when --self-update is invoked from a non-\n'
    printf '                    interactive shell (cron, systemd-run, postinst).\n'
    printf '  --self-update     Fetch the latest stable release from GitHub,\n'
    printf '                    preview it (incl. SHA-256 of the new bytes), and\n'
    printf '                    prompt before replacing the running script.\n'
    printf '                    Operator edits to the SYSADMIN WHITELIST region\n'
    printf '                    of the script are preserved via marker-bracketed\n'
    printf '                    splice. External whitelist files (--whitelist-\n'
    printf '                    file / default path) are NOT touched. Pair with\n'
    printf '                    --dry-run to preview without prompting, or -y to\n'
    printf '                    skip the prompt. For packaged installs prefer\n'
    printf '                    apt/dnf/pacman.\n'
    printf '  -V, --version     Show program version and exit\n'
    printf '  -h, --help        Show this help text and exit\n'
    printf '\n'
    printf 'Profiles:\n'
    printf '  minimal       Core filesystems + essential kernel modules only\n'
    printf '  conservative  Minimal + common server/VM drivers (default)\n'
    printf '  desktop       Conservative + WiFi, Bluetooth, audio, video drivers\n'
    printf '  none          No built-in baseline; only currently-loaded modules\n'
    printf '                and any --whitelist-file entries are preserved.\n'
    printf '                Recommended only with an explicit --whitelist-file.\n'
    printf '\n'
    printf 'Defaults:\n'
    printf '  profile:        conservative\n'
    printf '  output:         /etc/modprobe.d/modulejail-blacklist.conf\n'
    printf '  whitelist file: /etc/modulejail/whitelist.conf (auto-detected if present)\n'
    printf '\n'
    printf 'Exit codes:\n'
    printf '  0   success\n'
    printf '  64  command-line argument error (bad flag, missing value, unknown profile)\n'
    printf '  65  invalid data in whitelist file (malformed module name)\n'
    printf '  66  required kernel input missing (/proc/modules or /lib/modules/<kernel>)\n'
    printf '  70  sanity guard tripped (empty blacklist or >99%% of modules blacklisted)\n'
    printf '  71  OS-level error (mktemp work dir, or find errors on /lib/modules)\n'
    printf '  73  output path cannot be created (symlink/directory/trailing-slash, or mktemp failure)\n'
    printf '  77  target directory not writable (try sudo, or use -o <other-path>)\n'
    printf '\n'
    printf 'Environment:\n'
    printf '  MODULEJAIL_NO_UPDATE_CHECK   Set to any non-empty value to skip the post-run\n'
    printf '                               check for a newer release on GitHub.\n'
    printf '  MODULEJAIL_LOGGER_PATH       Path to the logger binary used for the syslog\n'
    printf '                               install-line detection (default: /usr/bin/logger).\n'
    printf '                               Test-only plumbing; end-user operators leave unset.\n'
    printf '  MODULEJAIL_DEFAULT_WHITELIST_FILE\n'
    printf '                               Override the auto-detected whitelist path\n'
    printf '                               (default: /etc/modulejail/whitelist.conf).\n'
    printf '                               Test-only plumbing; end-user operators leave unset.\n'
    printf '  MODULEJAIL_INITRAMFS_BUILDER\n'
    printf '                               Force the initramfs builder detection to a\n'
    printf '                               specific value (dracut, initramfs-tools, mkinitcpio)\n'
    printf '                               for the --install-initramfs-hook / --uninstall-\n'
    printf '                               initramfs-hook flags. Test-only plumbing; end-user\n'
    printf '                               operators leave unset.\n'
}

quote_arg() {
    case "$1" in
        ""|*[!a-zA-Z0-9._/=-]*)
            printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
            ;;
        *)
            printf '%s' "$1"
            ;;
    esac
}

# --- Initramfs strip hook helpers (--install-initramfs-hook etc.) ---
#
# modulejail's blacklist is a steady-state policy for the real root
# filesystem. Mainstream initramfs builders (dracut on RHEL/Rocky/Fedora,
# initramfs-tools on Debian/Ubuntu, mkinitcpio on Arch) all copy
# /etc/modprobe.d/*.conf into the initramfs cpio at build time. When a
# kernel upgrade triggers an initramfs rebuild, the then-current
# blacklist gets frozen into the new kernel's initramfs, and subsequent
# on-disk edits or full revocation do not update the baked copy. This
# causes the upgrade-then-stale class of bug (github.com/jnuyens/
# modulejail issue #19) and creates a boot-bricking risk when kernel
# upgrades rename storage drivers (mpt2sas -> mpi3mr, etc.).
#
# The strip hook removes modulejail-blacklist.conf from the initramfs
# being built. Security-neutral on the initrd side (no unprivileged
# users exist before pivot_root, so no LPE surface to defend), and
# closes the boot-bricking risk.

detect_initramfs_tool() {
    # Print the detected initramfs builder on stdout, or empty string.
    # Both the command AND its configuration directory must exist; this
    # distinguishes "tool is installed and active" from "a helper
    # subpackage is shipped" (e.g. dracut-install on Debian without
    # dracut itself being the active builder).
    #
    # MODULEJAIL_INITRAMFS_BUILDER overrides detection for test fixtures
    # (analogous to MODULEJAIL_PROC_MODULES / MODULEJAIL_LOGGER_PATH).
    # End-user operators leave unset.
    if [ -n "${MODULEJAIL_INITRAMFS_BUILDER:-}" ]; then
        printf '%s' "$MODULEJAIL_INITRAMFS_BUILDER"
        return 0
    fi
    if [ -d /usr/lib/dracut/modules.d ] && command -v dracut >/dev/null 2>&1; then
        printf 'dracut'
    elif [ -d /etc/initramfs-tools/hooks ] && command -v update-initramfs >/dev/null 2>&1; then
        printf 'initramfs-tools'
    elif [ -d /etc/initcpio/install ] && command -v mkinitcpio >/dev/null 2>&1; then
        printf 'mkinitcpio'
    fi
}

# install_initramfs_hook DRY_RUN
# Writes the strip hook for whichever initramfs builder is detected.
# Prints the operator-runnable rebuild command afterwards; does NOT
# rebuild the initramfs itself (sysadmin discipline replaces tool
# guardrails per project policy - operators schedule the rebuild on
# their own time, and packaged install scripts forward the same
# recommendation through dpkg/rpm/pacman output).
# DRY_RUN=1 prints what would happen without writing anything.
install_initramfs_hook() {
    dry_run=$1

    tool=$(detect_initramfs_tool)
    if [ -z "$tool" ]; then
        printf 'modulejail: error: no supported initramfs builder detected on this host\n' >&2
        printf 'modulejail: error: looked for dracut, initramfs-tools, mkinitcpio - none found\n' >&2
        exit $EX_OSERR
    fi

    case "$tool" in
        dracut)
            target_dir=/usr/lib/dracut/modules.d/99modulejail-strip
            target_file=$target_dir/module-setup.sh
            if [ "$dry_run" -eq 1 ]; then
                printf 'modulejail: dry-run: would write %s (0755)\n' "$target_file"
                # shellcheck disable=SC2016  # literal backticks intended for human-readable output
                printf 'modulejail: dry-run: would run `dracut --force --regenerate-all`\n'
                return 0
            fi
            mkdir -p "$target_dir"
            cat > "$target_file" <<'EOF'
#!/bin/sh
# Strip modulejail's blacklist from the initramfs being built.
# Installed by `modulejail --install-initramfs-hook`. The blacklist
# is a steady-state policy for the real root filesystem and should
# not constrain early-boot module loading.
# See github.com/jnuyens/modulejail issue #19.
check()   { return 0; }
depends() { return 0; }
install() {
    rm -f "${initdir}/etc/modprobe.d/modulejail-blacklist.conf"
}
EOF
            chmod 0755 "$target_file"
            printf 'modulejail: wrote dracut module: %s\n' "$target_file"
            # shellcheck disable=SC2016  # literal backticks intended for human-readable output
            printf 'modulejail: run `dracut --force --regenerate-all` to apply to existing initramfs images\n'
            ;;
        initramfs-tools)
            target_file=/etc/initramfs-tools/hooks/zz-modulejail-strip
            if [ "$dry_run" -eq 1 ]; then
                printf 'modulejail: dry-run: would write %s (0755)\n' "$target_file"
                # shellcheck disable=SC2016  # literal backticks intended for human-readable output
                printf 'modulejail: dry-run: would run `update-initramfs -u -k all`\n'
                return 0
            fi
            cat > "$target_file" <<'EOF'
#!/bin/sh
# Strip modulejail's blacklist from the initramfs being built.
# Installed by `modulejail --install-initramfs-hook`. The blacklist
# is a steady-state policy for the real root filesystem and should
# not constrain early-boot module loading.
# See github.com/jnuyens/modulejail issue #19.
PREREQ=""
prereqs() { echo "$PREREQ"; }
case $1 in
    prereqs) prereqs; exit 0 ;;
esac
rm -f "${DESTDIR}/etc/modprobe.d/modulejail-blacklist.conf"
EOF
            chmod 0755 "$target_file"
            printf 'modulejail: wrote initramfs-tools hook: %s\n' "$target_file"
            # shellcheck disable=SC2016  # literal backticks intended for human-readable output
            printf 'modulejail: run `update-initramfs -u -k all` to apply to existing initramfs images\n'
            ;;
        mkinitcpio)
            target_file=/etc/initcpio/install/modulejail-strip
            pacman_hook=/usr/share/libalpm/hooks/95-modulejail-strip.hook
            if [ "$dry_run" -eq 1 ]; then
                printf 'modulejail: dry-run: would write %s (0755)\n' "$target_file"
                if command -v pacman >/dev/null 2>&1 && [ -d /usr/share/libalpm/hooks ]; then
                    printf 'modulejail: dry-run: would write %s (0644)\n' "$pacman_hook"
                fi
                # shellcheck disable=SC2016  # literal backticks intended for human-readable output
                printf 'modulejail: dry-run: would run `mkinitcpio -P -- -A modulejail-strip`\n'
                return 0
            fi
            cat > "$target_file" <<'EOF'
#!/bin/bash
# Strip modulejail's blacklist from the initramfs being built.
# Installed by `modulejail --install-initramfs-hook`. The blacklist
# is a steady-state policy for the real root filesystem and should
# not constrain early-boot module loading.
# See github.com/jnuyens/modulejail issue #19.
build() {
    rm -f "${BUILDROOT}/etc/modprobe.d/modulejail-blacklist.conf"
}
help() {
    cat <<HELP
Strips /etc/modprobe.d/modulejail-blacklist.conf from the initramfs
cpio at build time. The blacklist is a steady-state policy for the
real root filesystem; baking it into the initramfs causes upgrade-
then-stale behavior (see github.com/jnuyens/modulejail issue #19).
HELP
}
EOF
            chmod 0755 "$target_file"
            printf 'modulejail: wrote mkinitcpio install hook: %s\n' "$target_file"
            if command -v pacman >/dev/null 2>&1 && [ -d /usr/share/libalpm/hooks ]; then
                cat > "$pacman_hook" <<'EOF'
# pacman trigger for the modulejail-strip mkinitcpio hook.
# Installed by `modulejail --install-initramfs-hook`. Rebuilds every
# initramfs with the modulejail-strip hook appended after any kernel
# install or upgrade.
# See github.com/jnuyens/modulejail issue #19.
[Trigger]
Type = Path
Operation = Install
Operation = Upgrade
Target = usr/lib/modules/*/vmlinuz

[Action]
Description = Stripping modulejail blacklist from rebuilt initramfs images...
When = PostTransaction
Exec = /usr/bin/mkinitcpio -P -- -A modulejail-strip
NeedsTargets
EOF
                chmod 0644 "$pacman_hook"
                printf 'modulejail: wrote pacman trigger: %s\n' "$pacman_hook"
            fi
            # shellcheck disable=SC2016  # literal backticks intended for human-readable output
            printf 'modulejail: run `mkinitcpio -P -- -A modulejail-strip` to apply to existing initramfs images\n'
            ;;
    esac

    printf 'modulejail: initramfs strip hook installed (detected builder: %s)\n' "$tool"
}

# uninstall_initramfs_hook DRY_RUN
# Removes every known hook file path regardless of which builder is
# currently detected (handles distro migrations). Prints the operator-
# runnable rebuild command afterwards; does NOT rebuild the initramfs
# itself. DRY_RUN=1 prints what would happen without removing anything.
uninstall_initramfs_hook() {
    dry_run=$1

    removed=0
    for path in \
        /usr/lib/dracut/modules.d/99modulejail-strip/module-setup.sh \
        /etc/initramfs-tools/hooks/zz-modulejail-strip \
        /etc/initcpio/install/modulejail-strip \
        /usr/lib/initcpio/install/modulejail-strip \
        /usr/share/libalpm/hooks/95-modulejail-strip.hook
    do
        if [ -e "$path" ]; then
            if [ "$dry_run" -eq 1 ]; then
                printf 'modulejail: dry-run: would remove %s\n' "$path"
            else
                rm -f "$path"
                printf 'modulejail: removed %s\n' "$path"
            fi
            removed=1
        fi
    done

    # Clean up the empty dracut module directory if it remains.
    if [ "$dry_run" -eq 0 ] && [ -d /usr/lib/dracut/modules.d/99modulejail-strip ]; then
        rmdir /usr/lib/dracut/modules.d/99modulejail-strip 2>/dev/null || true
    fi

    if [ "$removed" -eq 0 ]; then
        printf 'modulejail: no installed initramfs strip hooks found (already uninstalled, or never installed)\n'
        return 0
    fi

    printf 'modulejail: regenerate initramfs to refresh existing images:\n'
    printf '  dracut           --force --regenerate-all\n'
    printf '  update-initramfs -u -k all\n'
    printf '  mkinitcpio       -P\n'
    printf 'modulejail: initramfs strip hook uninstalled\n'
}

# --- Self-update helper (--self-update) ---
#
# Fetches the latest stable tag from the GitHub releases API, downloads
# the matching `modulejail` script, preserves operator edits to the
# SYSADMIN WHITELIST region (lines between `# === SYSADMIN WHITELIST ===`
# and `# === END SYSADMIN WHITELIST ===`), and atomically replaces the
# running script.
#
# Threat-model notes:
# - Trust anchor is HTTPS + GitHub. The fetched bytes are NOT signed
#   beyond TLS. Operators with a stricter trust model should rely on
#   their distro package manager (.deb / .rpm / AUR) instead. The
#   `--dry-run` mode displays the sha256 of the new script before any
#   write, so the operator can compare against the release page.
# - External whitelist files (--whitelist-file PATH or the default
#   /etc/modulejail/whitelist.conf) are NOT touched. They live outside
#   the script.
# - If the script's path is owned by a package manager (dpkg/rpm/pacman),
#   self-update will overwrite the package-managed file. A warning is
#   printed; operators should prefer `apt upgrade` / `dnf upgrade` /
#   `pacman -Syu` on packaged systems.

self_update() {
    auto_yes=$1
    dry_run=$2

    target_script=$(readlink -f "$0" 2>/dev/null || printf '%s' "$0")

    # Pick a fetcher.
    if command -v curl >/dev/null 2>&1; then
        fetcher_kind=curl
    elif command -v wget >/dev/null 2>&1; then
        fetcher_kind=wget
    else
        printf 'modulejail: error: --self-update requires curl or wget\n' >&2
        exit $EX_OSERR
    fi

    fetch_url() {
        # fetch_url URL OUTPATH -> 0 on success, non-zero on failure
        case "$fetcher_kind" in
            curl) curl -fsSL --max-time 30 -o "$2" "$1" ;;
            wget) wget -q -T 30 -O "$2" "$1" ;;
        esac
    }

    # Resolve latest stable tag from GitHub releases API.
    api_url='https://api.github.com/repos/jnuyens/modulejail/releases/latest'
    tag_file=$(mktemp "${TMPDIR:-/tmp}/modulejail-update.XXXXXX")
    if ! fetch_url "$api_url" "$tag_file"; then
        printf 'modulejail: error: cannot fetch %s\n' "$api_url" >&2
        rm -f "$tag_file"
        exit $EX_OSERR
    fi
    latest_tag=$(sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\(v[0-9][^"]*\)".*/\1/p' "$tag_file" | head -1)
    rm -f "$tag_file"
    if [ -z "$latest_tag" ]; then
        printf 'modulejail: error: could not parse latest tag from GitHub releases API\n' >&2
        exit $EX_OSERR
    fi

    current="v$VERSION"
    if [ "$latest_tag" = "$current" ]; then
        printf 'modulejail: already at latest stable: %s\n' "$current"
        exit $EX_OK
    fi

    # Download the new script.
    new_url="https://raw.githubusercontent.com/jnuyens/modulejail/${latest_tag}/modulejail"
    new_tmp=$(mktemp "${TMPDIR:-/tmp}/modulejail-new.XXXXXX")
    if ! fetch_url "$new_url" "$new_tmp"; then
        printf 'modulejail: error: cannot fetch %s\n' "$new_url" >&2
        rm -f "$new_tmp"
        exit $EX_OSERR
    fi

    # Sanity-check the downloaded bytes look like a modulejail script.
    if ! head -1 "$new_tmp" | grep -q '^#!/bin/sh'; then
        printf 'modulejail: error: downloaded file does not start with a /bin/sh shebang\n' >&2
        rm -f "$new_tmp"
        exit $EX_DATAERR
    fi
    if ! grep -q "^VERSION='" "$new_tmp"; then
        printf 'modulejail: error: downloaded file missing VERSION= line\n' >&2
        rm -f "$new_tmp"
        exit $EX_DATAERR
    fi

    # Compute SHA-256 of downloaded file for transparency.
    if command -v sha256sum >/dev/null 2>&1; then
        new_sha=$(sha256sum "$new_tmp" | awk '{print $1}')
    elif command -v shasum >/dev/null 2>&1; then
        new_sha=$(shasum -a 256 "$new_tmp" | awk '{print $1}')
    else
        new_sha='(no sha256sum/shasum available on host)'
    fi

    # Preserve operator-edited SYSADMIN WHITELIST region.
    start_marker='# === SYSADMIN WHITELIST ==='
    end_marker='# === END SYSADMIN WHITELIST ==='
    current_region=$(mktemp "${TMPDIR:-/tmp}/modulejail-region.XXXXXX")
    awk -v start="$start_marker" -v end="$end_marker" '
        $0 == start { in_region=1 }
        in_region   { print }
        $0 == end   { in_region=0 }
    ' "$target_script" > "$current_region"

    splice_status='preserved (spliced from current script)'
    if [ ! -s "$current_region" ]; then
        splice_status='NOT preserved (current script has no SYSADMIN WHITELIST markers)'
        rm -f "$current_region"
        current_region=''
    elif ! grep -qF "$start_marker" "$new_tmp" || ! grep -qF "$end_marker" "$new_tmp"; then
        splice_status='NOT preserved (downloaded script has no SYSADMIN WHITELIST markers)'
        rm -f "$current_region"
        current_region=''
    fi

    spliced_tmp=$(mktemp "${TMPDIR:-/tmp}/modulejail-spliced.XXXXXX")
    if [ -n "$current_region" ]; then
        awk -v start="$start_marker" -v end="$end_marker" -v region_file="$current_region" '
            $0 == start {
                while ((getline line < region_file) > 0) print line
                close(region_file)
                skip = 1
                next
            }
            $0 == end {
                skip = 0
                next
            }
            !skip { print }
        ' "$new_tmp" > "$spliced_tmp"
        rm -f "$current_region"
    else
        cat "$new_tmp" > "$spliced_tmp"
    fi
    rm -f "$new_tmp"

    # Detect packaged install for an informative warning.
    # Trailing `|| true` is load-bearing on every branch: dpkg-query, rpm,
    # and pacman all exit non-zero when the queried path is NOT owned by a
    # package, and the resulting non-zero exit from the assignment-via-
    # command-substitution trips `set -e` at the top of the script,
    # silently aborting self-update mid-flight.
    pkg_warning=''
    if command -v dpkg-query >/dev/null 2>&1; then
        pkg=$( { dpkg-query -S "$target_script" 2>/dev/null || true; } | cut -d: -f1 | head -1)
        [ -n "$pkg" ] && pkg_warning="dpkg package: $pkg"
    fi
    if [ -z "$pkg_warning" ] && command -v rpm >/dev/null 2>&1; then
        pkg=$(rpm -qf "$target_script" 2>/dev/null || true)
        # rpm -qf prints "file X is not owned by any package" (spaces + "not owned")
        # or a single NVR string for owned files. The "not owned" substring is
        # the discriminator; checking it alone is enough.
        case "$pkg" in
            ''|*'not owned'*) : ;;
            *) pkg_warning="rpm package: $pkg" ;;
        esac
    fi
    if [ -z "$pkg_warning" ] && command -v pacman >/dev/null 2>&1; then
        pkg=$( { pacman -Qoq "$target_script" 2>/dev/null || true; } | head -1)
        [ -n "$pkg" ] && pkg_warning="pacman package: $pkg"
    fi

    # Preview
    printf 'modulejail: self-update preview:\n'
    printf '  current version: %s\n' "$current"
    printf '  latest version:  %s\n' "$latest_tag"
    printf '  source URL:      %s\n' "$new_url"
    printf '  target path:     %s\n' "$target_script"
    printf '  sha256 of new:   %s\n' "$new_sha"
    printf '  SYSADMIN WHITELIST region: %s\n' "$splice_status"
    printf '  external whitelist file (--whitelist-file or %s): not touched\n' "$DEFAULT_WHITELIST_FILE"
    if [ -n "$pkg_warning" ]; then
        printf '\n'
        printf 'modulejail: NOTE: %s is managed by %s.\n' "$target_script" "$pkg_warning"
        printf 'modulejail: NOTE: prefer your package manager for upgrades; self-update will be\n'
        printf 'modulejail: NOTE: overwritten by the next package transaction.\n'
    fi

    if [ "$dry_run" -eq 1 ]; then
        printf '\nmodulejail: dry-run: would replace %s with the above\n' "$target_script"
        rm -f "$spliced_tmp"
        return 0
    fi

    if [ "$auto_yes" -ne 1 ]; then
        # Without -y / --yes, prompt interactively. Pick the right input
        # source: stdin if it's a tty, otherwise /dev/tty if it's actually
        # openable (the `[ -e ]` test is not enough - /dev/tty is always
        # present in /dev/ but only opens when there is a controlling
        # terminal). When neither is available (postinst, cron,
        # systemd-run, ssh non-interactive), refuse and tell the operator
        # to pass -y / --yes - silent self-update from a non-interactive
        # context is not a safe default.
        if [ -t 0 ]; then
            input_source='stdin'
        elif (: < /dev/tty) 2>/dev/null; then
            input_source='tty'
        else
            input_source='none'
        fi

        if [ "$input_source" = 'none' ]; then
            printf '\nmodulejail: error: non-interactive shell; pass -y / --yes to apply\n' >&2
            rm -f "$spliced_tmp"
            exit $EX_USAGE
        fi

        printf '\nApply this update? [y/N] '
        if [ "$input_source" = 'tty' ]; then
            read -r response < /dev/tty || response=''
        else
            read -r response || response=''
        fi
        case "$response" in
            [yY]|[yY][eE][sS]) ;;
            *)
                printf 'modulejail: self-update cancelled by operator\n'
                rm -f "$spliced_tmp"
                return 0
                ;;
        esac
    fi

    # Atomic replace via rename(2) within the target directory.
    target_dir=$(dirname "$target_script")
    if ! final_tmp=$(mktemp "${target_dir}/.modulejail.new.XXXXXX" 2>/dev/null); then
        printf 'modulejail: error: cannot create temp file in %s (need write access)\n' "$target_dir" >&2
        rm -f "$spliced_tmp"
        exit $EX_CANTCREAT
    fi
    cat "$spliced_tmp" > "$final_tmp"
    chmod 0755 "$final_tmp"
    mv "$final_tmp" "$target_script"
    rm -f "$spliced_tmp"

    printf '\nmodulejail: self-update applied: %s -> %s\n' "$current" "$latest_tag"
    printf 'modulejail: target: %s\n' "$target_script"
}

# --- Reproducible invocation string ---
# When the script is piped into sh (like: `curl ... | sh`) $0 is 'sh' and $@ empty.
# In other cases (even `sh -c '...'`) $invocation is script + arguments, properly quoted.
invocation=$(quote_arg "$0")
for arg in "$@"; do
    invocation="$invocation $(quote_arg "$arg")"
done

# --- Argument parser (manual case loop — POSIX, supports long options) ---
while [ $# -gt 0 ]; do
    case "$1" in
        -p|--profile)
            [ $# -ge 2 ] || { printf 'modulejail: error: %s requires an argument\n' "$1" >&2; exit $EX_USAGE; }
            profile=$2
            shift 2
            ;;
        --profile=*)
            profile=${1#--profile=}
            shift
            ;;
        -o|--output)
            [ $# -ge 2 ] || { printf 'modulejail: error: %s requires an argument\n' "$1" >&2; exit $EX_USAGE; }
            output=$2
            shift 2
            ;;
        --output=*)
            output=${1#--output=}
            shift
            ;;
        --whitelist-file)
            [ $# -ge 2 ] || { printf 'modulejail: error: --whitelist-file requires PATH argument\n' >&2; exit $EX_USAGE; }
            WHITELIST_FILE="$2"
            shift 2
            ;;
        --whitelist-file=*)
            WHITELIST_FILE="${1#*=}"
            [ -n "$WHITELIST_FILE" ] || { printf 'modulejail: error: --whitelist-file= requires non-empty PATH\n' >&2; exit $EX_USAGE; }
            shift
            ;;
        --no-whitelist-file)
            NO_WHITELIST_FILE=1
            shift
            ;;
        --no-syslog-logging)
            NO_SYSLOG_LOGGING=1
            shift
            ;;
        -f|--fail-on-module-load)
            FAIL_ON_MODULE_LOAD=1
            shift
            ;;
        --verbose-logging)
            VERBOSE_LOGGING=1
            shift
            ;;
        --dry-run)
            # shellcheck disable=SC2034
            DRY_RUN=1
            shift
            ;;
        --quiet)
            QUIET=1
            shift
            ;;
        --verbose)
            VERBOSE=1
            shift
            ;;
        --output-format)
            [ $# -ge 2 ] || { printf 'modulejail: error: --output-format requires json or logfmt argument\n' >&2; exit $EX_USAGE; }
            OUTPUT_FORMAT="$2"
            shift 2
            ;;
        --output-format=*)
            OUTPUT_FORMAT="${1#*=}"
            [ -n "$OUTPUT_FORMAT" ] || { printf 'modulejail: error: --output-format= requires non-empty argument (expected json or logfmt)\n' >&2; exit $EX_USAGE; }
            shift
            ;;
        --install-initramfs-hook)
            INITRAMFS_HOOK_ACTION='install'
            shift
            ;;
        --uninstall-initramfs-hook)
            INITRAMFS_HOOK_ACTION='uninstall'
            shift
            ;;
        -y|--yes)
            ASSUME_YES=1
            shift
            ;;
        --self-update)
            SELF_UPDATE=1
            shift
            ;;
        -h|--help)
            usage
            exit $EX_OK
            ;;
        -V|--version)
            printf 'modulejail %s\n' "$VERSION"
            printf 'https://modulejail.com\n'
            printf 'https://github.com/jnuyens/modulejail\n'
            printf 'License: GPL-3.0-only (https://spdx.org/licenses/GPL-3.0-only.html)\n'
            exit $EX_OK
            ;;
        --)
            shift
            break
            ;;
        -*)
            printf 'modulejail: error: unknown option: %s\n' "$1" >&2
            exit $EX_USAGE
            ;;
        *)
            printf 'modulejail: error: unexpected positional argument: %s\n' "$1" >&2
            exit $EX_USAGE
            ;;
    esac
done

# --- Self-update dispatch ---
# Mutually exclusive with --install/--uninstall-initramfs-hook (different
# top-level actions). --dry-run shows the preview without writing;
# --yes applies the download.
if [ "$SELF_UPDATE" -eq 1 ] && [ -n "$INITRAMFS_HOOK_ACTION" ]; then
    printf 'modulejail: error: --self-update and --install/--uninstall-initramfs-hook are mutually exclusive\n' >&2
    exit $EX_USAGE
fi
if [ "$SELF_UPDATE" -eq 1 ]; then
    self_update "$ASSUME_YES" "$DRY_RUN"
    exit $EX_OK
fi

# --- Initramfs hook install/uninstall dispatch ---
# When --install-initramfs-hook or --uninstall-initramfs-hook is set,
# take that action and exit. The action is orthogonal to blacklist
# generation; all other flags (profile, whitelist, etc.) are ignored
# in this mode. --dry-run is honored as "show what would happen."
if [ -n "$INITRAMFS_HOOK_ACTION" ]; then
    if [ "$(id -u)" -ne 0 ] && [ "$DRY_RUN" -eq 0 ]; then
        printf 'modulejail: error: --%s-initramfs-hook requires root (writes to /usr/lib and /etc)\n' \
            "$INITRAMFS_HOOK_ACTION" >&2
        exit $EX_NOPERM
    fi
    case "$INITRAMFS_HOOK_ACTION" in
        install)   install_initramfs_hook   "$DRY_RUN" ;;
        uninstall) uninstall_initramfs_hook "$DRY_RUN" ;;
    esac
    exit $EX_OK
fi

# --- Validate profile ---
case "$profile" in
    minimal|conservative|desktop|none) ;;
    *) printf 'modulejail: error: unknown profile: %s (expected minimal, conservative, desktop, none)\n' "$profile" >&2; exit $EX_USAGE ;;
esac

# --- Reject contradictory whitelist-file flags ---
# Explicit-path-plus-opt-out is ambiguous; refuse rather than silently
# letting one win.
if [ -n "$WHITELIST_FILE" ] && [ -n "$NO_WHITELIST_FILE" ]; then
    printf 'modulejail: error: --whitelist-file PATH and --no-whitelist-file are mutually exclusive\n' >&2
    exit $EX_USAGE
fi

# --- Reject --quiet + --verbose (mutually exclusive) ---
if [ "$QUIET" -eq 1 ] && [ "$VERBOSE" -eq 1 ]; then
    printf 'modulejail: error: --quiet and --verbose are mutually exclusive\n' >&2
    exit $EX_USAGE
fi

# --- Reject --no-syslog-logging + --verbose-logging (mutually exclusive) ---
# --verbose-logging enriches the per-line `logger` call with the caller's
# PID/loginuid/exe; --no-syslog-logging forces the v1.1.4-style /bin/true
# install lines that have no logger call to enrich. Combining the two is
# self-contradictory.
if [ -n "$NO_SYSLOG_LOGGING" ] && [ "$VERBOSE_LOGGING" -eq 1 ]; then
    printf 'modulejail: error: --no-syslog-logging and --verbose-logging are mutually exclusive\n' >&2
    exit $EX_USAGE
fi

# --- Validate --output-format closed set ---
if [ -n "$OUTPUT_FORMAT" ]; then
    case "$OUTPUT_FORMAT" in
        json|logfmt) ;;
        *) printf 'modulejail: error: unknown --output-format: %s (expected json or logfmt)\n' "$OUTPUT_FORMAT" >&2; exit $EX_USAGE ;;
    esac
fi

# --- Default whitelist file auto-detection ---
# Per Issue #2 followup (bpmartin20, james-rimu, 2026-05): a default path
# under /etc/modulejail/ catches the "forgot the flag" silent-error case.
# Only kick in when:
#   - user did not pass --whitelist-file PATH, AND
#   - user did not pass --no-whitelist-file, AND
#   - the default file exists.
# Validation/permission gate inside parse_whitelist_file runs the same way
# as for an explicit path — an existing but malformed/unsafe default file
# is a loud error, never a silent skip.
if [ -z "$WHITELIST_FILE" ] && [ -z "$NO_WHITELIST_FILE" ] && [ -e "$DEFAULT_WHITELIST_FILE" ]; then
    WHITELIST_FILE="$DEFAULT_WHITELIST_FILE"
    if [ "$QUIET" -eq 0 ]; then
        printf 'modulejail: info: using default whitelist file %s (--no-whitelist-file to opt out)\n' "$DEFAULT_WHITELIST_FILE" >&2
    fi
fi

# --- -p none sanity-guard-disabled notice ---
# The >99% blacklist threshold guard is skipped under -p none (D-Phase5-02).
# Emit an info: line so fleet log scrapers have a parseable breadcrumb.
if [ "$profile" = "none" ] && [ "$QUIET" -eq 0 ]; then
    printf 'modulejail: info: -p none selected; the >99%% blacklist sanity guard is disabled (the loaded-keep invariant remains the boot-safety contract).\n' >&2
fi

# --- Logger detection (generation-time, one-shot per run) ---
# The opt-out flag NO_SYSLOG_LOGGING wins. Otherwise, emit the logger
# install-line iff the running host has an executable logger binary.
# This is a one-shot check at generation time (not a per-modprobe
# eval). The emitted install line itself uses `; exit 0` for
# heterogeneous-fleet robustness (so a missing logger at modprobe
# time still returns 0).
#
# Path is overridable via MODULEJAIL_LOGGER_PATH for test fixtures
# (test-only plumbing, cf. MODULEJAIL_PROC_MODULES / MODULEJAIL_KVER /
# MODULEJAIL_MODULES_ROOT). End-user operators leave it unset; the
# default is /usr/bin/logger.
if [ -z "$NO_SYSLOG_LOGGING" ] && [ -x "${MODULEJAIL_LOGGER_PATH:-/usr/bin/logger}" ]; then
    USE_LOGGER=1
fi

# --verbose-logging requires the logger binary to be present (the
# enriched install-line invokes logger). If logger is absent, fail
# loudly rather than silently falling back to the v1.1.4 /bin/true
# form (nothing to enrich).
if [ "$VERBOSE_LOGGING" -eq 1 ] && [ -z "$USE_LOGGER" ]; then
    printf 'modulejail: error: --verbose-logging requires the logger binary at %s (install util-linux or override via MODULEJAIL_LOGGER_PATH)\n' \
        "${MODULEJAIL_LOGGER_PATH:-/usr/bin/logger}" >&2
    exit $EX_NOINPUT
fi

# --verbose-logging also requires `tr` (coreutils) - the enriched
# install-line pipes /proc/$PPID/cmdline through tr to convert NULs
# to spaces and strip control bytes. Without tr the install-line would
# emit `tr: command not found` into syslog, mangling the log entry.
# Test override: MODULEJAIL_TR_PATH (analogous to MODULEJAIL_LOGGER_PATH).
TR_PATH=${MODULEJAIL_TR_PATH:-/usr/bin/tr}
if [ "$VERBOSE_LOGGING" -eq 1 ] && [ ! -x "$TR_PATH" ]; then
    printf 'modulejail: error: --verbose-logging requires tr at %s (install coreutils or override via MODULEJAIL_TR_PATH)\n' \
        "$TR_PATH" >&2
    exit $EX_NOINPUT
fi

# --- Pre-flight: verify this is a Linux host with the required kernel ---
# interfaces. Run this BEFORE the writability gate so non-Linux operators
# get an accurate "not a Linux host" diagnostic instead of a misleading
# "cannot write to /etc/modprobe.d" message.
# /proc/modules path is overridable via MODULEJAIL_PROC_MODULES for
# test fixtures that cannot expose a synthetic /proc (test-only plumbing,
# cf. TMPDIR, GIT_DIR). End-user operators leave it unset.
if [ ! -e "${MODULEJAIL_PROC_MODULES:-/proc/modules}" ]; then
    printf 'modulejail: error: %s not found (is this a Linux host?)\n' \
        "${MODULEJAIL_PROC_MODULES:-/proc/modules}" >&2
    exit $EX_NOINPUT
fi
# Kernel version overridable via MODULEJAIL_KVER for test fixtures
# (test-only plumbing; containers share the host kernel via uname -r,
# so synthetic module trees need a pinned version).
_kver="${MODULEJAIL_KVER:-$(uname -r)}"
# Modules-tree root overridable via MODULEJAIL_MODULES_ROOT for test
# fixtures (test-only plumbing, cf. MODULEJAIL_PROC_MODULES / MODULEJAIL_KVER).
# End-user operators leave it unset (defaults to /lib/modules). This lets
# host-local case scripts on a non-Linux dev box build a synthetic tree under
# $TMPDIR and exercise the full pipeline without requiring root or a writable
# /lib/modules.
_modules_root="${MODULEJAIL_MODULES_ROOT:-/lib/modules}"
if [ ! -d "$_modules_root/$_kver" ]; then
    printf 'modulejail: error: %s/%s does not exist (kernel module tree missing)\n' "$_modules_root" "$_kver" >&2
    exit $EX_NOINPUT
fi

# --- Pre-flight: reject $output that is (or could become) a directory. ---
# Without this, `-o /etc/modprobe.d` (or any existing directory — easy slip
# with shell completion or a copy-paste from docs) would pass dirname, pass
# the writability gate, and have `mv` quietly drop the temp file *inside*
# the directory under its randomized name. modprobe ignores dotfiles, so
# the host would silently end up unprotected. Catch both forms here.
if [ -d "$output" ]; then
    printf 'modulejail: error: -o %s is a directory; expected a file path\n' "$output" >&2
    exit $EX_CANTCREAT
fi
case "$output" in
    */) printf 'modulejail: error: -o %s ends with /, expected a file path\n' "$output" >&2; exit $EX_CANTCREAT ;;
esac

# --- Pre-flight: refuse a symlinked output target. ---
# `mv -- "$tmp" "$output"` would follow the symlink and replace the *target*,
# not the link. On a multi-tenant or misconfigured host this can write
# outside /etc/modprobe.d/. Honour the atomic-rename safety story by
# refusing the symlink up front rather than silently following it.
if [ -L "$output" ]; then
    printf 'modulejail: error: refusing to overwrite symlink at %s\n' "$output" >&2
    exit $EX_CANTCREAT
fi

# --- Pre-flight: writability gate (before any heavy work) ---
target_dir=$(dirname "$output")
if ! [ -w "$target_dir" ]; then
    printf 'modulejail: error: cannot write to %s (try sudo, or use -o <other-path>)\n' "$target_dir" >&2
    exit $EX_NOPERM
fi

# === PIPELINE ===

# Per-invocation work directory — all intermediate sorted files live here.
# Cleaned up on any exit path (success, failure, signal).
# Use the explicit-template form rather than `mktemp -t modulejail.XXXXXX`:
# the `-t` flag has three incompatible meanings across GNU coreutils, BSD,
# and busybox (Alpine). The full-path template form works identically on
# all of them and is self-documenting about where the directory lives.
workdir=$(mktemp -d "${TMPDIR:-/tmp}/modulejail.XXXXXX") || {
    printf 'modulejail: error: cannot create temporary work directory\n' >&2
    exit $EX_OSERR
}

# Install the consolidated cleanup trap NOW (before the second mktemp in the
# render block), so a signal arriving in the window between creating $tmp and
# registering it does not orphan the in-target dotfile under /etc/modprobe.d/.
# $tmp is initially empty; rm -f on an empty argument is a no-op, so the trap
# is safe to fire before $tmp is assigned.
tmp=
# shellcheck disable=SC2329  # invoked via trap string, not direct call
cleanup() {
    # shellcheck disable=SC2317  # invoked via trap string, not direct call
    rm -rf "$workdir"
    # shellcheck disable=SC2317  # invoked via trap string, not direct call
    if [ -n "${tmp:-}" ]; then rm -f "$tmp"; fi
    # Always return 0 from the EXIT trap. Under dash/POSIX /bin/sh, an
    # EXIT trap whose last command exits non-zero CLOBBERS the script's
    # original exit status. The previous `[ -n "$tmp" ] && rm -f "$tmp"`
    # idiom returned 1 whenever $tmp was still empty (every error path
    # before the render block), masking explicit `exit $EX_*` codes.
    # Surfaced by the new whitelist-file rejection paths (Plan 03-01).
    # shellcheck disable=SC2317  # invoked via trap string, not direct call
    return 0
}
trap cleanup EXIT INT HUP TERM

# list_universe: walk the FULL modules tree at $_modules_root/$_kver
# (default: /lib/modules/$(uname -r), NOT just kernel/) so DKMS and
# weak-updates modules are included.
# Strip exactly the four known suffixes (.ko, .ko.gz, .ko.xz, .ko.zst);
# lines that do not match a known suffix are dropped (precision filter for
# the *.ko* glob). Normalize - to _. Output is sorted, deduped.
#
# POSIX /bin/sh has no `pipefail`, so a `find ... | awk ... | sort -u`
# pipeline silently masks find failures (a permission-denied subtree under
# weak-updates/, a broken symlink chain, transient I/O) and produces a
# partial universe, which then under-blacklists the host invisibly.
# Capture find's stderr to a tempfile in $workdir, run find with stdout
# to another tempfile, and abort if stderr is non-empty.
list_universe() {
    find_out=$workdir/find.out
    find_err=$workdir/find.err
    find "$_modules_root/$_kver" -type f -name '*.ko*' \
        >"$find_out" 2>"$find_err"
    if [ -s "$find_err" ]; then
        printf 'modulejail: error: find reported errors walking %s/%s:\n' \
            "$_modules_root" "$_kver" >&2
        cat "$find_err" >&2
        printf 'modulejail: error: refusing to produce a partial blacklist.\n' >&2
        exit $EX_OSERR
    fi
    awk '{
        n = $0
        # Keep only the basename
        sub(/.*\//, "", n)
        # Strip exactly one of the four known compression suffixes; drop others
        if (!sub(/\.ko\.gz$/, "", n) && \
            !sub(/\.ko\.xz$/, "", n) && \
            !sub(/\.ko\.zst$/, "", n) && \
            !sub(/\.ko$/, "", n)) next
        # Normalize - to _ (canonical underscore form)
        gsub(/-/, "_", n)
        # Defense-in-depth: reject names containing
        # anything outside canonical kernel-module characters before
        # they can land in /etc/modprobe.d/ install lines. The strict
        # regex gate is what the project documentation positions as
        # the safety guarantee for modprobe.d directives; without
        # this line that guarantee only held for the --whitelist-file
        # path (parse_whitelist_file) and not for the filesystem-walk
        # path. Write access to /lib/modules/$KVER/ is root-equivalent
        # today, so this is defense-in-depth, not a CVE fix. A legit
        # kernel module basename always matches this regex post
        # dash-to-underscore normalization; nothing real is dropped.
        if (n !~ /^[a-zA-Z0-9_]+$/) next
        print n
    }' "$find_out" | sort -u
}

# list_loaded: read currently-loaded modules from /proc/modules.
# Uses /proc/modules directly — no kmod/lsmod runtime dep.
# Column 1 of /proc/modules is already in canonical underscore form.
list_loaded() {
    # /proc/modules path overridable via MODULEJAIL_PROC_MODULES
    # (test-only plumbing; see pre-flight gate above).
    #
    # Defense-in-depth: apply the same canonical-name
    # filter as list_universe for symmetry. Column 1 of /proc/modules
    # is kernel-owned and trustworthy in practice, but the strict-regex
    # gate that the project documentation positions as the guarantee
    # for modprobe.d directives should hold uniformly across every
    # source that feeds emit_install_line. A real loaded module always
    # matches this regex; nothing legitimate is dropped.
    awk '$1 ~ /^[a-zA-Z0-9_]+$/ {print $1}' \
        "${MODULEJAIL_PROC_MODULES:-/proc/modules}" | sort -u
}

# Produce the two canonical input files for set arithmetic.
list_universe > "$workdir/universe.txt"
list_loaded   > "$workdir/loaded.txt"

# list_baseline: expand the selected baseline profile ($profile) into a sorted,
# deduplicated list of canonical underscore module names.
# BASELINE_* variables are POSIX space-separated strings (NOT bash arrays).
# We expand them via intentional word-splitting.
list_baseline() {
    list=''
    case "$profile" in
        none) ;;
        minimal)      list=$BASELINE_MINIMAL ;;
        conservative) list=$BASELINE_CONSERVATIVE ;;
        desktop)      list=$BASELINE_DESKTOP ;;
    esac
    # intentional word-splitting — BASELINE_* are POSIX space-separated lists, not bash arrays
    # shellcheck disable=SC2086
    for x in $list; do
        printf '%s\n' "$x"
    done \
    | tr '-' '_' \
    | sort -u
}

# list_whitelist: expand $WHITELIST into a sorted, deduplicated list of
# canonical underscore module names. Parallel to list_baseline above.
list_whitelist() {
    # intentional word-splitting — WHITELIST is a POSIX space-separated list, not bash array
    # shellcheck disable=SC2086
    for x in $WHITELIST; do
        printf '%s\n' "$x"
    done \
    | tr '-' '_' \
    | sort -u
}

# parse_whitelist_file: validate and parse the optional --whitelist-file.
# Stdin: none. Stdout: zero or more module names, one per line (unsorted).
# Side effects: errors to stderr and exits non-zero on any rejection.
# Tolerates leading and trailing whitespace per line: the awk
# program strips both before validating against the canonical regex, so
# an indented entry copy-pasted from a YAML or other indented source
# parses cleanly rather than tripping EX_DATAERR. Blank lines and lines
# whose first non-blank char is `#` are skipped.
# Exit codes:
#   65 (EX_DATAERR) - malformed module name on any non-comment line
#                     (awk's `END { if (!ok) exit 65 }` data-error path)
#   66 (EX_NOINPUT) - file does not exist or is unreadable
#   71 (EX_OSERR)   - awk failed for a non-data reason (OOM, signal, future
#                     program-edit syntax error); distinguished from EX_DATAERR
#                     so fleet automation that case-splits on sysexits codes
#                     reads correctly. (Previously this branch was dead
#                     code under set -eu because awk's non-zero exit
#                     aborted the shell at the awk line before we could remap.)
#   77 (EX_NOPERM)  - file is not owned by root, or is group- or world-writable
parse_whitelist_file() {
    _wf="$1"
    [ -n "$_wf" ] || return 0   # no file passed; no-op
    if [ ! -r "$_wf" ]; then
        printf 'modulejail: error: whitelist file %s does not exist or is not readable\n' "$_wf" >&2
        exit $EX_NOINPUT
    fi
    # Ownership check: file must be owned by root (uid 0) OR by the
    # current user. A file owned by a third uid is a privilege-boundary
    # crossing - if modulejail runs as root, a non-root owner could have
    # injected WHITELIST entries that root would then trust. (The
    # current-uid carve-out lets a developer run modulejail against a
    # whitelist file in their own home for testing without raising the
    # bar to "must be /etc/modulejail/whitelist.conf".)
    # Use stat with GNU-form first, BSD-form fallback (macOS, *BSD).
    _owner=$(stat -c '%u' "$_wf" 2>/dev/null || stat -f '%u' "$_wf" 2>/dev/null || echo '')
    if [ -z "$_owner" ]; then
        printf 'modulejail: error: could not stat whitelist file %s\n' "$_wf" >&2
        exit $EX_OSERR
    fi
    _current_uid=$(id -u)
    if [ "$_owner" != "0" ] && [ "$_owner" != "$_current_uid" ]; then
        printf 'modulejail: error: whitelist file %s is owned by uid %s; must be owned by root (uid 0) or by you (uid %s); run '\''sudo chown root:root %s'\'' and retry\n' "$_wf" "$_owner" "$_current_uid" "$_wf" >&2
        exit $EX_NOPERM
    fi
    # Permission check: refuse if group- or world-writable. Use stat with
    # GNU-form first, BSD-form fallback (macOS, *BSD). The result is an
    # octal mode string like "644", "664", "666", or "0644" (some stats
    # include the setuid/setgid/sticky leading digit).
    _mode=$(stat -c '%a' "$_wf" 2>/dev/null || stat -f '%Lp' "$_wf" 2>/dev/null || echo '')
    if [ -z "$_mode" ]; then
        printf 'modulejail: error: could not stat whitelist file %s\n' "$_wf" >&2
        exit $EX_OSERR
    fi
    # POSIX shell has no portable octal arithmetic (bash's $((8#$x)) is
    # SC3052: non-POSIX). Inspect the group and world octal digits as
    # strings instead. A digit with the 2-bit set (i.e. write bit) is one
    # of 2,3,6,7.
    _last3=$(printf '%s' "$_mode" | awk '{ n=length($0); print substr($0, n-2, 3) }')
    _group_digit=$(printf '%s' "$_last3" | cut -c2)
    _other_digit=$(printf '%s' "$_last3" | cut -c3)
    case "$_group_digit$_other_digit" in
        *[2367]*)
            printf 'modulejail: error: whitelist file %s must not be group- or world-writable; run '\''sudo chmod go-w %s'\'' and retry\n' "$_wf" "$_wf" >&2
            exit $EX_NOPERM
            ;;
    esac
    # Parse the file. Use awk for line-numbered validation - we want the
    # line number in error messages so operators can find the bad entry.
    #
    # Bracket the awk invocation with `set +e` / `set -e` so we can
    # capture the real return code and route it to a typed sysexits exit.
    # Without this, `set -eu` at line 5 aborts the shell at the awk line on
    # any non-zero awk exit, making the dispatch below dead code. Inside
    # the awk program, the only
    # documented non-zero exit is `END { if (!ok) exit 65 }`, which maps
    # to EX_DATAERR (65). Any other non-zero exit indicates an awk-internal
    # failure (OOM, killed by signal, future program-edit syntax error),
    # routed to EX_OSERR (71). Two distinct codes; fleet automation case-
    # splitting on `$?` reads correctly.
    set +e
    awk -v file="$_wf" '
        BEGIN { ok = 1 }
        # Strip CR (handle CRLF line endings gracefully).
        { sub(/\r$/, "") }
        # Strip trailing whitespace.
        { sub(/[[:space:]]+$/, "") }
        # Strip leading whitespace symmetric with the trailing
        # strip above, so a naturally-indented module name (e.g.
        # "  vfio_pci" copy-pasted from a YAML or other indented
        # source) is accepted rather than rejected as EX_DATAERR.
        # Blank-line and comment-line skips below already tolerated
        # leading whitespace via their own ^[[:space:]]* patterns; this
        # extends that tolerance to module-name lines for symmetry.
        { sub(/^[[:space:]]+/, "") }
        # Skip blank lines.
        /^[[:space:]]*$/ { next }
        # Skip comment lines.
        /^[[:space:]]*#/ { next }
        # Validate.
        $0 !~ /^[a-zA-Z0-9_-]+$/ {
            printf "modulejail: error: whitelist file %s line %d: invalid module name %s (must match [a-zA-Z0-9_-]+)\n", file, NR, $0 > "/dev/stderr"
            ok = 0
            next
        }
        { print }
        END { if (!ok) exit 65 }
    ' "$_wf"
    _awk_status=$?
    set -e
    case "$_awk_status" in
        0)   ;;                       # clean parse
        65)  exit $EX_DATAERR ;;      # documented data-error path
        *)   printf 'modulejail: error: awk failed parsing whitelist file %s (rc=%d)\n' \
                 "$_wf" "$_awk_status" >&2
             exit $EX_OSERR ;;
    esac
}

# emit_install_line: read sorted module names from $1 (one per line) and
# print one modprobe.d-format install line per module. Branches on
# USE_LOGGER (set by the generation-time logger detection above).
#
# When USE_LOGGER is set: emit the logger form
#   install <name> /bin/sh -c '/usr/bin/logger -t modulejail "blocked: <name>" 2>/dev/null; exit 0'
# When USE_LOGGER is empty: emit the v1.1.4 form
#   install <name> /bin/true
# When FAIL_ON_MODULE_LOAD is set, the trailing `exit 0` becomes `/bin/false`
# and the bare `/bin/true` becomes `/bin/false`, so modprobe returns
# non-zero for blacklisted modules. The default-off (`FAIL_ON_MODULE_LOAD=0`)
# install-line bytes are unchanged from v1.2.2 by construction.
#
# awk-quoting note: the outer single-quotes around the awk program are
# closed and reopened around each literal single-quote that has to land
# in the install-line output ('"'"'"'"' is the canonical sh idiom). The
# inner double-quotes around "blocked: %s" are part of the install-line
# string sh will eval at modprobe time. printf's %s is interpolated at
# generation time by awk, so the emitted line carries a literal module
# name, not a $name reference.
emit_install_line() {
    if [ -n "$USE_LOGGER" ]; then
        if [ "$VERBOSE_LOGGING" -eq 1 ]; then
            # Verbose form (v1.3.4+, fixed in v1.3.5 and v1.3.6 per
            # @retry-the-user in #18): enrich the logger call with the
            # caller's PID, loginuid, parent comm, and argv[0] read
            # from /proc/$PPID/.
            #
            # Three key properties:
            #
            # 1. NO `/bin/sh -c '...'` wrapper. modprobe invokes install
            #    commands via system() = sh -c "<command>", so wrapping
            #    the logger call in `/bin/sh -c '...'` would create a
            #    second shell layer, making $PPID point at the wrapper
            #    sh (not at modprobe). Without the wrapper, modprobe's
            #    own sh -c is the only shell layer and $PPID resolves
            #    to modprobe.
            #
            # 2. cmdline is piped through tr twice: first to strip
            #    control bytes (\\x01-\\x08 \\x0b-\\x1f \\x7f), then to
            #    convert NUL to space so the argv elements show as
            #    space-separated. Why tr-then-tr instead of cat: shell
            #    command substitution strips NULs from captured output,
            #    which would concatenate argv elements ("modprobecpuid").
            #    Why strip control bytes: defense-in-depth against log
            #    injection via attacker-controlled cmdline content
            #    (newlines, terminal-control sequences). Shell command
            #    substitution treats the substituted text as data (no
            #    second expansion pass), so command injection through
            #    cmdline content is not possible; this hardens against
            #    log injection only.
            #
            # 3. Backslash escapes in the install-line text are DOUBLED
            #    (`\\\\001` etc. emitted, modprobe collapses to `\\001`
            #    in the file, sh reads it as `\\001`, tr parses as
            #    octal NUL-byte-1). modprobe's libkmod config parser
            #    processes `\\\\` → `\\` itself before the install
            #    command reaches sh. v1.3.5 emitted single-backslash
            #    `\\001` which modprobe collapsed to bare `001`, giving
            #    tr the digit string `001-010013-037177` whose
            #    "1-0" substring tr correctly rejected as a reverse
            #    range. Verified on Ubuntu 24.04 with kmod 31.
            if [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
                awk -v tr="$TR_PATH" '{
                    printf "install %s /usr/bin/logger -t modulejail \"blocked: %s ppid=$PPID pcomm=$(cat /proc/$PPID/comm 2>/dev/null) loginuid=$(cat /proc/$PPID/loginuid 2>/dev/null) pexe=$(%s -d '\''\\\\001-\\\\010\\\\013-\\\\037\\\\177'\'' < /proc/$PPID/cmdline 2>/dev/null | %s '\''\\\\0'\'' '\'' '\'' 2>/dev/null)\" 2>/dev/null; exit 0\n", $1, $1, tr, tr
                }' "$1"
            else
                awk -v tr="$TR_PATH" '{
                    printf "install %s /usr/bin/logger -t modulejail \"blocked: %s ppid=$PPID pcomm=$(cat /proc/$PPID/comm 2>/dev/null) loginuid=$(cat /proc/$PPID/loginuid 2>/dev/null) pexe=$(%s -d '\''\\\\001-\\\\010\\\\013-\\\\037\\\\177'\'' < /proc/$PPID/cmdline 2>/dev/null | %s '\''\\\\0'\'' '\'' '\'' 2>/dev/null)\" 2>/dev/null; /bin/false\n", $1, $1, tr, tr
                }' "$1"
            fi
        elif [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
            # Default: byte-identical to v1.2.2 (trailing `; exit 0`).
            awk '{
                printf "install %s /bin/sh -c '\''/usr/bin/logger -t modulejail \"blocked: %s\" 2>/dev/null; exit 0'\''\n", $1, $1
            }' "$1"
        else
            # Fail mode: `; /bin/false` instead of `; exit 0`, so the
            # whole install command returns non-zero and modprobe fails.
            awk '{
                printf "install %s /bin/sh -c '\''/usr/bin/logger -t modulejail \"blocked: %s\" 2>/dev/null; /bin/false'\''\n", $1, $1
            }' "$1"
        fi
    else
        if [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
            # Default: byte-identical to v1.1.4 / v1.2.2 silent form.
            awk '{print "install " $1 " /bin/true"}' "$1"
        else
            # Fail mode: /bin/false makes modprobe fail loudly.
            awk '{print "install " $1 " /bin/false"}' "$1"
        fi
    fi
}

# render_blacklist_file: write the 8-line header block followed by all
# install directives to stdout. Callers redirect stdout to the desired
# destination (a tempfile for the real-write path, stderr for dry-run).
# Using a single function for both paths removes the duplication that
# previously made drift between the two branches a silent regression risk.
# All referenced variables (VERSION, profile, _kver, fingerprint,
# USE_LOGGER, FAIL_ON_MODULE_LOAD, invocation, blacklist) are resolved at
# call time from the enclosing shell scope.
render_blacklist_file() {
    printf '# modulejail %s\n' "$VERSION"
    printf '# https://modulejail.com\n'
    printf '# profile: %s\n' "$profile"
    printf '# kernel: %s\n' "$_kver"
    printf '# fingerprint: sha256:%s\n' "$fingerprint"
    if [ -n "$USE_LOGGER" ]; then
        if [ "$VERBOSE_LOGGING" -eq 1 ]; then
            if [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
                printf '# install-line: /bin/sh + logger + ppid/loginuid/pcomm/pexe context (syslog tag: modulejail, --verbose-logging)\n'
            else
                printf '# install-line: /bin/sh + logger + ppid/loginuid/pcomm/pexe context + /bin/false (syslog tag: modulejail, --verbose-logging --fail-on-module-load)\n'
            fi
        elif [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
            printf '# install-line: /bin/sh + logger (syslog tag: modulejail)\n'
        else
            printf '# install-line: /bin/sh + logger + /bin/false (syslog tag: modulejail, --fail-on-module-load)\n'
        fi
    else
        if [ "$FAIL_ON_MODULE_LOAD" -eq 0 ]; then
            printf '# install-line: /bin/true (silent, --no-syslog-logging or logger absent)\n'
        else
            printf '# install-line: /bin/false (silent, --fail-on-module-load)\n'
        fi
    fi
    printf '# invocation: %s\n' "$invocation"
    printf '# Do not edit by hand — regenerate with modulejail(8).\n'
    emit_install_line "$blacklist"
}

# _json_escape: escape a string for safe embedding inside a JSON double-quoted
# value. Handles \, ", and ASCII control characters (U+0000–U+001F). Output is
# the escaped form of $1 on stdout (no surrounding quotes added by this helper;
# the caller wraps in "…"). POSIX-portable: awk only. The ord[] table is
# built in BEGIN by iterating the first 128 ASCII code points.
_json_escape() {
    printf '%s' "$1" | awk 'BEGIN {
        for (i = 0; i < 128; i++) ord[sprintf("%c", i)] = i
    }
    {
        out = ""
        n = length($0)
        for (i = 1; i <= n; i++) {
            c = substr($0, i, 1)
            v = (c in ord) ? ord[c] : -1
            if (c == "\\")      { out = out "\\\\" }
            else if (c == "\"") { out = out "\\\"" }
            else if (v >= 0 && v < 32) { out = out sprintf("\\u%04x", v) }
            else                { out = out c }
        }
        printf "%s", out
    }'
}

# Machine-readable one-line summary (--output-format json|logfmt, OPT-04).
# Called by the three-way stdout branch when --output-format is set;
# bypasses --quiet per D-Phase5-09. Schema v1 locked (D-Phase5-10):
# 11 fields, additive-only in future versions.
emit_machine_summary() {
    _fmt=$1
    _modules_loaded=$(wc -l < "$workdir/loaded.txt" | awk '{print $1}')
    if [ -n "$WHITELIST_FILE" ]; then
        _wl_json="\"$(_json_escape "$WHITELIST_FILE")\""
        _wl_lf="$WHITELIST_FILE"
    else
        _wl_json='null'
        _wl_lf=''
    fi
    if [ "$DRY_RUN" -eq 1 ]; then
        _dry='true'
    else
        _dry='false'
    fi
    case "$_fmt" in
        json)
            printf '{"schema_version":1,"tool":{"name":"modulejail","version":"%s"},"kernel_version":"%s","profile":"%s","dry_run":%s,"whitelist_file":%s,"modules_available":%d,"modules_loaded":%d,"modules_blacklisted":%d,"fingerprint":"%s","output_path":"%s"}\n' \
                "$(_json_escape "$VERSION")" "$(_json_escape "$_kver")" \
                "$(_json_escape "$profile")" "$_dry" "$_wl_json" \
                "$universe_count" "$_modules_loaded" "$blacklist_count" \
                "$(_json_escape "$fingerprint")" "$(_json_escape "$output")"
            ;;
        logfmt)
            printf 'schema_version=1 tool_name=modulejail tool_version=%s kernel_version=%s profile=%s dry_run=%s whitelist_file=%s modules_available=%d modules_loaded=%d modules_blacklisted=%d fingerprint=%s output_path=%s\n' \
                "$VERSION" "$_kver" "$profile" "$_dry" "$_wl_lf" \
                "$universe_count" "$_modules_loaded" "$blacklist_count" \
                "$fingerprint" "$output"
            ;;
    esac
}

list_baseline > "$workdir/baseline.txt"
# parse_whitelist_file may exit non-zero (EX_NOINPUT / EX_NOPERM /
# EX_DATAERR / EX_OSERR). POSIX /bin/sh has no `pipefail`, so the helper
# must run OUTSIDE any pipeline for its exits to propagate to the
# top-level script. Capture its output to a tempfile, then merge with
# list_whitelist via sort -u.
parse_whitelist_file "$WHITELIST_FILE" > "$workdir/whitelist-file.txt"
# Normalize dash to underscore, parallel to list_whitelist / list_baseline /
# list_universe. The manpage and README document that whitelist entries may
# be written in either form ("nft-compat" or "nft_compat"); /proc/modules
# and the universe walker always report the underscore form, so without
# this normalization a dash-form entry never joined the keep-set and the
# module was silently blacklisted. (Caught by the v1.2 code-review gate.)
{
    list_whitelist
    tr '-' '_' < "$workdir/whitelist-file.txt"
} | sort -u > "$workdir/whitelist.txt"

# Compose total keep-list: loaded modules union baseline union whitelist (sorted, deduped).
cat "$workdir/loaded.txt" "$workdir/baseline.txt" "$workdir/whitelist.txt" | sort -u > "$workdir/keep.txt"

# Set difference: blacklist = universe − keep.
# comm -23 requires both inputs to be sorted; both universe.txt and keep.txt are.
comm -23 "$workdir/universe.txt" "$workdir/keep.txt" > "$workdir/blacklist.txt"

# Per-module decision lines for --verbose (D-Phase5-07, OPT-03).
# Single awk pass over the four sorted files: first-write-wins precedence
# (loaded > whitelist > baseline). One fork, O(n). Decision lines go to stderr;
# no severity prefix (operator-requested telemetry, not script-status diagnostics).
# This block sits outside any QUIET guard: --quiet and --verbose are mutually
# exclusive (Plan 05-01 mutex guard fires before we reach this point).
if [ "$VERBOSE" -eq 1 ]; then
    awk '
        FILENAME ~ /loaded\.txt$/    { src[$1] = "loaded";    seen_universe[$1]=0; next }
        FILENAME ~ /whitelist\.txt$/ { if (!($1 in src)) src[$1] = "whitelist"; next }
        FILENAME ~ /baseline\.txt$/  { if (!($1 in src)) src[$1] = "baseline"; next }
        { if ($1 in src) { seen_universe[$1]=1; printf "keep: %s (%s)\n", $1, src[$1] }
          else printf "blacklist: %s\n", $1 }
        END {
            for (m in src) if (!(m in seen_universe) || seen_universe[m] == 0)
                printf "keep: %s (%s, out-of-tree)\n", m, src[m]
        }
    ' "$workdir/loaded.txt" "$workdir/whitelist.txt" "$workdir/baseline.txt" \
      "$workdir/universe.txt" >&2
fi

# Expose result path for the renderer below.
# shellcheck disable=SC2034  # consumed by the render block via $blacklist
blacklist="$workdir/blacklist.txt"

# Sanity guard: abort on degenerate output to avoid bricking a host with
# an empty or near-total blacklist.
universe_count=$(wc -l < "$workdir/universe.txt")
blacklist_count=$(wc -l < "$workdir/blacklist.txt")

if [ "$blacklist_count" -eq 0 ]; then
    printf 'modulejail: error: computed blacklist is empty; nothing to write.\n' >&2
    printf 'modulejail: error: check that /proc/modules is readable and the baseline list is correct.\n' >&2
    exit $EX_SOFTWARE
fi

if [ "$profile" != "none" ] && [ "$((blacklist_count * 100))" -gt "$((universe_count * 99))" ]; then
    printf 'modulejail: error: computed blacklist covers %d of %d modules (>99%% of universe).\n' \
        "$blacklist_count" "$universe_count" >&2
    printf 'modulejail: error: This usually means /proc/modules was unreadable or the baseline list is wrong.\n' >&2
    printf 'modulejail: error: Aborting to avoid an unbootable host.\n' >&2
    exit $EX_SOFTWARE
fi

# === FINGERPRINT ===
# Hash a canonical serialization of (sorted loaded, sorted baseline,
# sorted whitelist, profile, kernel) so two runs with byte-identical
# inputs produce byte-identical headers. NO wall-clock anywhere: the
# header is a deterministic function of inputs, so idempotency is by
# construction.
# Use plain `sha256sum` (no flags); busybox lacks `--tag`.
{
    printf 'modulejail-fingerprint:v1\n'
    printf 'profile:%s\n' "$profile"
    printf 'kernel:%s\n' "$_kver"
    printf 'loaded:\n'
    cat "$workdir/loaded.txt"
    printf 'baseline:\n'
    cat "$workdir/baseline.txt"
    printf 'whitelist:\n'
    cat "$workdir/whitelist.txt"
} > "$workdir/fingerprint.input"
fingerprint=$(sha256sum < "$workdir/fingerprint.input" | awk '{print $1}')

# === RENDER ===
# Render $blacklist into install /bin/true directives and write atomically to $output.
# Atomic write: mktemp in same dir as $output → chmod 0644 → mv (single rename(2)).
# This guarantees readers see either the pre-run file or the post-run file, never a
# partial write.

# target_dir already computed above (writability gate); reuse it. The
# consolidated cleanup trap was installed earlier (right after $workdir)
# and already knows about $tmp (initially empty), so the post-mktemp
# assignment is atomic from the trap's point of view — no signal window
# can orphan this dotfile in $target_dir.
if [ "$DRY_RUN" -eq 1 ]; then
    # --dry-run: skip mktemp/chmod/mv entirely. Nothing touches $target_dir.
    # render_blacklist_file output is rerouted to stderr so operators can
    # inspect the header and install directives that would have been written
    # without persisting them. Under --quiet, output is silenced entirely
    # (--quiet --dry-run produces zero output; exit code is the only signal).
    if [ "$QUIET" -eq 0 ]; then
        render_blacklist_file >&2
    fi
else
    tmp=$(mktemp "${target_dir}/.modulejail-blacklist.conf.XXXXXX") || {
        printf 'modulejail: error: cannot create temp file in %s\n' "$target_dir" >&2
        exit $EX_CANTCREAT
    }

    # Write 8-line header + one install line per blacklisted module via
    # render_blacklist_file, which uses the same code path as --dry-run.
    # The install line takes one of two forms depending on the generation-time
    # logger detection (see USE_LOGGER above); emit_install_line picks the right
    # form and the header documents which one was chosen.
    # NO wall-clock; header is a deterministic function of inputs.
    # Fingerprint is computed above the render block.
    render_blacklist_file > "$tmp"

    # Set world-readable permissions before rename (umask may be too restrictive;
    # modprobe.d files must be readable by all).
    chmod 0644 "$tmp"

    # Atomic rename: single rename(2) syscall; same filesystem guaranteed because
    # $tmp is in $(dirname "$output").
    mv -- "$tmp" "$output"
fi

# Stdout summary: three-way branch (D-Phase5-09, D-Phase5-12).
# D-Phase5-12: --output-format REPLACES the human-readable summary.
# D-Phase5-09: JSON/logfmt IS the parser surface; --quiet does NOT silence it.
if [ -n "$OUTPUT_FORMAT" ]; then
    # Machine-readable: emits regardless of --quiet (D-Phase5-09).
    emit_machine_summary "$OUTPUT_FORMAT"
elif [ "$QUIET" -eq 0 ]; then
    if [ "$DRY_RUN" -eq 1 ]; then
        printf 'modulejail: DRY-RUN: would blacklist %d of %d modules (profile=%s) -> %s\n' \
            "$blacklist_count" "$universe_count" "$profile" "$output"
    else
        printf 'modulejail: blacklisted %d of %d modules (profile=%s) -> %s\n' \
            "$blacklist_count" "$universe_count" "$profile" "$output"
    fi
fi

# === UPDATE CHECK ===
# Best-effort, silent on any failure. Fetches the GitHub "latest release"
# redirect target with a 10-second hard timeout and prints a stderr notice
# only when the discovered tag is strictly newer than VERSION.
# Honours MODULEJAIL_NO_UPDATE_CHECK=<any non-empty value> to skip.

# ver_cmp: pure-shell SemVer X.Y.Z comparator.
# Prints 1 if $1 > $2, -1 if $1 < $2, 0 otherwise. Missing or non-numeric
# components are treated as 0 (defensive: garbage in, 0 out, never error).
ver_cmp() {
    a=$1; b=$2
    a1=${a%%.*}; a_rest=${a#*.}
    case "$a_rest" in "$a") a_rest=0.0 ;; esac
    a2=${a_rest%%.*}; a_rest=${a_rest#*.}
    case "$a_rest" in "$a2") a_rest=0 ;; esac
    a3=${a_rest%%.*}
    b1=${b%%.*}; b_rest=${b#*.}
    case "$b_rest" in "$b") b_rest=0.0 ;; esac
    b2=${b_rest%%.*}; b_rest=${b_rest#*.}
    case "$b_rest" in "$b2") b_rest=0 ;; esac
    b3=${b_rest%%.*}
    case "$a1" in ''|*[!0-9]*) a1=0 ;; esac
    case "$a2" in ''|*[!0-9]*) a2=0 ;; esac
    case "$a3" in ''|*[!0-9]*) a3=0 ;; esac
    case "$b1" in ''|*[!0-9]*) b1=0 ;; esac
    case "$b2" in ''|*[!0-9]*) b2=0 ;; esac
    case "$b3" in ''|*[!0-9]*) b3=0 ;; esac
    if [ "$a1" -gt "$b1" ]; then echo 1; return; fi
    if [ "$a1" -lt "$b1" ]; then echo -1; return; fi
    if [ "$a2" -gt "$b2" ]; then echo 1; return; fi
    if [ "$a2" -lt "$b2" ]; then echo -1; return; fi
    if [ "$a3" -gt "$b3" ]; then echo 1; return; fi
    if [ "$a3" -lt "$b3" ]; then echo -1; return; fi
    echo 0
}

# check_for_updates: best-effort latest-tag lookup against the GitHub
# tags API. Always returns 0; the only externally visible effect is a
# possible stderr notice when a strictly-newer tag is reachable.
# URL is overridable via the undocumented MODULEJAIL_UPDATE_URL
# (test-only plumbing, cf. MODULEJAIL_PROC_MODULES and MODULEJAIL_KVER
# above). The tags API is used rather than the /releases/latest
# redirect because it works whether or not GitHub Release objects have
# been created for the tags.
check_for_updates() {
    [ -n "${MODULEJAIL_NO_UPDATE_CHECK:-}" ] && return 0
    update_url=${MODULEJAIL_UPDATE_URL:-https://api.github.com/repos/jnuyens/modulejail/tags}

    body=
    if command -v curl >/dev/null 2>&1; then
        # -f: fail on HTTP 4xx/5xx. -s: silent. -L: follow redirects.
        # --max-time 10: hard wall-clock cap.
        body=$(curl -fsL --max-time 10 "$update_url" 2>/dev/null) || return 0
    elif command -v wget >/dev/null 2>&1; then
        # Use the universal short-flag subset accepted by both GNU wget
        # and busybox wget (Alpine ships only the latter). busybox wget
        # rejects --max-redirect / --output-document / --quiet long forms
        # and exits non-zero, which previously made the update check
        # silently fail on Alpine. The GitHub tags API serves directly
        # at api.github.com without redirecting, so -L / --max-redirect
        # are not needed.
        body=$(wget -q -T 10 -O - "$update_url" 2>/dev/null) || return 0
    else
        printf 'modulejail: notice: no curl/wget in PATH, cannot check for update\n' >&2
        return 0
    fi

    # Extract the first "name": "..." value from the JSON array.
    # The tags API returns tags newest-first, so the first match is the
    # latest. Awk on " as the delimiter pulls field 4 from
    #     "name": "vX.Y.Z",
    # which is "vX.Y.Z" (without quotes).
    latest_tag=$(printf '%s\n' "$body" \
                 | awk -F'"' '/^[[:space:]]*"name"[[:space:]]*:/ { print $4; exit }')
    [ -n "$latest_tag" ] || return 0

    latest_ver=${latest_tag#v}
    case "$latest_ver" in
        [0-9]*.[0-9]*.[0-9]*) ;;
        *) return 0 ;;
    esac

    if [ "$(ver_cmp "$latest_ver" "$VERSION")" = "1" ]; then
        printf 'modulejail: notice: a newer release is available: v%s (you have %s)\n' \
            "$latest_ver" "$VERSION" >&2
        printf 'modulejail: notice: https://github.com/jnuyens/modulejail/releases/latest\n' >&2
        printf 'modulejail: notice: set MODULEJAIL_NO_UPDATE_CHECK=1 to disable this check\n' >&2
    fi
    return 0
}

if [ "$QUIET" -eq 0 ]; then
    check_for_updates
fi

exit $EX_OK
