diff options
38 files changed, 2656 insertions, 377 deletions
@@ -167,6 +167,17 @@ validate_username() { done } +# Validate a tri-state config value: empty (the default) passes, otherwise it +# must be exactly "yes" or "no". Returns non-zero and names the offending +# variable on a bad value so validate_config can abort. +validate_yesno() { + local name="$1" value="$2" + if [[ -n "$value" && "$value" != "yes" && "$value" != "no" ]]; then + echo "ERROR: $name must be 'yes' or 'no'. Got: '$value'" >&2 + return 1 + fi +} + # Validate values pulled from --config-file, before anything is installed, so a # typo (DESKTOP_ENV=hyperland) fails immediately instead of partway through the # install. NOT a security boundary: load_config sources the config as bash, so a @@ -180,22 +191,10 @@ validate_config() { exit 1 fi - if [[ -n "$AUTOLOGIN" && "$AUTOLOGIN" != "yes" && "$AUTOLOGIN" != "no" ]]; then - echo "ERROR: AUTOLOGIN must be 'yes' or 'no'. Got: '$AUTOLOGIN'" >&2 - exit 1 - fi - if [[ -n "$NO_GPU_DRIVERS" && "$NO_GPU_DRIVERS" != "yes" && "$NO_GPU_DRIVERS" != "no" ]]; then - echo "ERROR: NO_GPU_DRIVERS must be 'yes' or 'no'. Got: '$NO_GPU_DRIVERS'" >&2 - exit 1 - fi - if [[ -n "$INSTALL_CLAUDE_CODE" && "$INSTALL_CLAUDE_CODE" != "yes" && "$INSTALL_CLAUDE_CODE" != "no" ]]; then - echo "ERROR: INSTALL_CLAUDE_CODE must be 'yes' or 'no'. Got: '$INSTALL_CLAUDE_CODE'" >&2 - exit 1 - fi - if [[ -n "$INSTALL_DEVICE_UDEV_RULES" && "$INSTALL_DEVICE_UDEV_RULES" != "yes" && "$INSTALL_DEVICE_UDEV_RULES" != "no" ]]; then - echo "ERROR: INSTALL_DEVICE_UDEV_RULES must be 'yes' or 'no'. Got: '$INSTALL_DEVICE_UDEV_RULES'" >&2 - exit 1 - fi + validate_yesno AUTOLOGIN "$AUTOLOGIN" || exit 1 + validate_yesno NO_GPU_DRIVERS "$NO_GPU_DRIVERS" || exit 1 + validate_yesno INSTALL_CLAUDE_CODE "$INSTALL_CLAUDE_CODE" || exit 1 + validate_yesno INSTALL_DEVICE_UDEV_RULES "$INSTALL_DEVICE_UDEV_RULES" || exit 1 if [[ -n "$locale" && ! "$locale" =~ ^[a-z]{2,3}(_[A-Z]{2})?(\.[A-Za-z0-9-]+)?(@[A-Za-z]+)?$ ]]; then echo "ERROR: LOCALE looks malformed: '$locale'. Expected e.g. en_US.UTF-8" >&2 @@ -253,6 +252,8 @@ packages_after="/var/log/archsetup-post-install-package-list.txt" archsetup_packages="/var/log/archsetup-installed-packages.txt" min_disk_space_gb=20 +# Minimum NVIDIA driver major version for usable Wayland/Hyprland. +NVIDIA_MIN_DRIVER=535 state_dir="/var/lib/archsetup/state" error_messages=() errors_encountered=0 @@ -287,22 +288,29 @@ mark_complete() { } run_step() { - step_name="$1" - step_func="$2" + local step_name="$1" + local step_func="$2" if step_completed "$step_name"; then printf "Skipping %s (already completed)\n" "$step_name" return 0 fi + # A step function that returns here has already passed every fatal check -- + # error_fatal exits the script outright, so it never reaches this point on a + # fatal error. A non-zero return can therefore only be a trailing non-fatal + # warning (error_warn returns 1), and the step's body still ran to + # completion. Record the marker either way so resume does not re-run a step + # that already did its work; surface a warning line when the return was + # non-zero. if $step_func; then mark_complete "$step_name" return 0 - else - printf "FAILED: %s\n" "$step_name" - printf "To retry this step, remove: %s/%s\n" "$state_dir" "$step_name" - return 1 fi + local rc=$? + printf "Step %s completed with warnings (last status %s)\n" "$step_name" "$rc" + mark_complete "$step_name" + return 0 } show_status() { @@ -315,6 +323,7 @@ show_status() { exit 0 fi echo "Completed steps:" + local step timestamp for step in "${STEPS[@]}"; do if step_completed "$step"; then timestamp=$(cat "$state_dir/$step") @@ -456,7 +465,7 @@ nvidia_preflight_report() { [ "$found" = "true" ] || return 0 echo " [!!] NVIDIA GPU detected." - echo " Wayland/Hyprland on NVIDIA needs driver 535+ and explicit" + echo " Wayland/Hyprland on NVIDIA needs driver ${NVIDIA_MIN_DRIVER}+ and explicit" echo " environment variables; expect rougher edges than AMD/Intel:" echo " LIBVA_DRIVER_NAME=nvidia" echo " GBM_BACKEND=nvidia-drm" @@ -468,32 +477,47 @@ nvidia_preflight_report() { local ver major ver=$(pacman -Si nvidia-utils 2>/dev/null | awk '/^Version/ {print $3; exit}') major="${ver%%.*}" - if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge 535 ]; then - echo " [OK] NVIDIA driver candidate: ${ver} (>= 535)" + if [[ "$major" =~ ^[0-9]+$ ]] && [ "$major" -ge "$NVIDIA_MIN_DRIVER" ]; then + echo " [OK] NVIDIA driver candidate: ${ver} (>= ${NVIDIA_MIN_DRIVER})" return 10 fi echo "ERROR: NVIDIA driver requirement not met" - echo " Required: driver 535 or newer for usable Wayland" + echo " Required: driver ${NVIDIA_MIN_DRIVER} or newer for usable Wayland" echo " Repo offers: ${ver:-unknown (pacman -Si nvidia-utils failed)}" echo " Fix: refresh the package database (pacman -Syy) and retry, or" echo " install with DESKTOP_ENV=dwm (X11) instead." return 11 } -preflight_checks() { - echo "Running pre-flight checks..." - - # Check disk space (need at least 20GB free on root partition) - available_kb=$(df / | awk 'NR==2 {print $4}') - available_gb=$((available_kb / 1024 / 1024)) - if [ "$available_gb" -lt "$min_disk_space_gb" ]; then +check_disk_space() { + # df -P forces POSIX single-line output; plain `df` wraps to two lines when + # the source device name is long (live ISO / device-mapper root), which + # made NR==2 read the device line and $4 come back empty. Compare in KB + # directly rather than truncating to GB first, which biased the gate + # against the user near the threshold. + local available_kb min_kb + available_kb=$(df -P / | awk 'NR==2 {print $4}') + # Treat empty or non-numeric output (df failed / malformed) as zero so the + # gate aborts loudly instead of crashing the arithmetic comparison. + case "$available_kb" in + ''|*[!0-9]*) available_kb=0 ;; + esac + min_kb=$((min_disk_space_gb * 1024 * 1024)) + if [ "$available_kb" -lt "$min_kb" ]; then echo "ERROR: Insufficient disk space" echo " Required: ${min_disk_space_gb}GB" - echo " Available: ${available_gb}GB" + echo " Available: $((available_kb / 1024 / 1024))GB" echo " Free up disk space before running archsetup." exit 1 fi - echo " [OK] Disk space: ${available_gb}GB available" + echo " [OK] Disk space: $((available_kb / 1024 / 1024))GB available" +} + +preflight_checks() { + echo "Running pre-flight checks..." + + # Check disk space (need at least 20GB free on root partition) + check_disk_space # Check network connectivity if ! ping -c 1 -W 5 archlinux.org > /dev/null 2>&1; then @@ -1023,15 +1047,17 @@ bootstrap_pacman_keyring() { # so transient mirror stalls recover instead of killing the run. action="refreshing the package cache" && display "task" "$action" refresh_ok=false + refresh_rc=0 for attempt in $(seq 1 "$MAX_INSTALL_RETRIES"); do - if pacman -Syu --noconfirm >> "$logfile" 2>&1; then - refresh_ok=true - break - fi + pacman -Syu --noconfirm >> "$logfile" 2>&1 && { refresh_ok=true; break; } + # Capture pacman's real exit here: after `if pacman; then ...; fi` bash + # reports the compound's status (0 when the condition was false), so + # error_fatal would otherwise log "error code: 0" for a genuine failure. + refresh_rc=$? [ "$attempt" -lt "$MAX_INSTALL_RETRIES" ] && \ display "task" "retrying package cache refresh (attempt $((attempt + 1))/$MAX_INSTALL_RETRIES)" done - $refresh_ok || error_fatal "$action" "$?" "run pacman -Syu manually to see the failure, or switch mirrors in /etc/pacman.d/mirrorlist" + $refresh_ok || error_fatal "$action" "$refresh_rc" "run pacman -Syu manually to see the failure, or switch mirrors in /etc/pacman.d/mirrorlist" } @@ -1039,7 +1065,7 @@ install_required_software() { display "subtitle" "Required Software" for software in linux-firmware wireless-regdb base-devel ca-certificates \ - chrony coreutils curl git go openssh python \ + chrony coreutils curl git go inetutils openssh python \ stow tar vim zsh; do pacman_install "$software" done @@ -1106,6 +1132,72 @@ configure_build_environment() { } +# Guarded install of an executable: copy <src> to <dest>, then chmod +x. With +# set -e off, a bare cp/chmod on a missing or partial source failed silently and +# left a dead ExecStart or a pacman hook pointing at a script that never landed. +# Each step warns on failure and the chmod is skipped when the copy did not +# land. +install_executable() { + local src="$1" dest="$2" + if cp "$src" "$dest" >> "$logfile" 2>&1; then + chmod +x "$dest" >> "$logfile" 2>&1 || error_warn "chmod +x $dest" "$?" + else + error_warn "copying $src to $dest" "$?" + return 1 + fi +} + +# Append <line> to the crontab piped in on stdin, but only when no existing +# line contains <match>. Idempotent so a resume re-run of the step never stacks +# a duplicate entry. Emits the resulting crontab on stdout. +crontab_append_once() { + local match="$1" line="$2" existing + existing="$(cat)" + [ -n "$existing" ] && printf '%s\n' "$existing" + printf '%s\n' "$existing" | grep -qF "$match" || printf '%s\n' "$line" +} + +# Read ZFS pool names on stdin (one per line) and print one scrub-timer unit +# per non-empty pool. No pools in -> no output, so the caller warns instead of +# enabling the malformed unit `zfs-scrub-weekly@.timer`; several pools each get +# their own timer rather than an arbitrary `head -1` pick. +zfs_scrub_timer_units() { + local pool + while read -r pool; do + [ -n "$pool" ] && printf 'zfs-scrub-weekly@%s.timer\n' "$pool" + done +} + +# Enable a systemd --user service at install time by creating the wants symlink +# directly. `systemctl --user enable` fails during install (no user session +# bus), so the link is wired by hand -- the pattern syncthing and gamemode both +# need. Args: <username> <service-unit> <service-file> [home-dir]. +enable_user_service() { + local username="$1" service="$2" service_file="$3" + local home="${4:-/home/$username}" + local wants_dir="$home/.config/systemd/user/default.target.wants" + mkdir -p "$wants_dir" + ln -sf "$service_file" "$wants_dir/$service" + chown -R "$username:$username" "$home/.config/systemd" +} + +# Replace /etc/sudoers with its .pacnew only after visudo validates it. A +# malformed pacnew that sudo refuses to parse would lock out privilege +# escalation, so a validation failure warns (non-fatal) and keeps the working +# file. Paths are positional args defaulting to the system paths so the guard +# can be exercised against fixtures. +replace_sudoers_pacnew() { + local pacnew="${1:-/etc/sudoers.pacnew}" + local target="${2:-/etc/sudoers}" + [ -f "$pacnew" ] || return 0 + if visudo -cf "$pacnew" >> "$logfile" 2>&1; then + cp "$pacnew" "$target" >> "$logfile" 2>&1 \ + || error_warn "copying validated $pacnew to $target" "$?" + else + error_warn "$pacnew failed visudo validation; keeping current sudoers" "1" + fi +} + configure_package_mirrors() { action="Package Mirrors" && display "subtitle" "$action" pacman_install reflector @@ -1143,7 +1235,7 @@ EOF error_warn "$action" "$?" action="replacing sudoers file if new package version exists" && display "task" "$action" - [ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers >> "$logfile" 2>&1 + replace_sudoers_pacnew action="creating a directory to build/install software from git/AUR." (mkdir -p "$source_dir") || error_fatal "creating the directory $source_dir" "$?" "check permissions and free space: df -h" @@ -1151,6 +1243,14 @@ EOF } ### Create User +set_user_password() { + # $1 = username; reads $password. set -e is off (line 21), so a bare + # `chpasswd` that failed would silently leave the user with no password and + # no log entry. Guard it: a failure aborts loudly instead. + echo "$1:$password" | chpasswd 2>> "$logfile" \ + || error_fatal "setting password for $1" "$?" "set it by hand: passwd $1" +} + create_user() { display "title" "User Creation" @@ -1165,7 +1265,7 @@ create_user() { error_fatal "adding user '$username'" "$?" "run the useradd manually to see why it failed" display "task" "assigning the password" - echo "$username:$password" | chpasswd # any text is allowable! be careful! + set_user_password "$username" unset password # clear from memory after use display "task" "adding to appropriate groups" @@ -1710,8 +1810,9 @@ configure_job_scheduling() { run_task "enabling the batch delayed command scheduler" systemctl enable atd action="installing log cleanup cron job" && display "task" "$action" - (sudo -u "$username" crontab -l 2>/dev/null; \ - echo "0 12 * * * \$HOME/.local/bin/cron/log-cleanup") \ + sudo -u "$username" crontab -l 2>/dev/null \ + | crontab_append_once 'cron/log-cleanup' \ + "0 12 * * * \$HOME/.local/bin/cron/log-cleanup" \ | sudo -u "$username" crontab - \ >> "$logfile" 2>&1 || error_warn "$action" "$?" } @@ -1817,8 +1918,7 @@ configure_zfs_snapshots() { EOF action="installing zfs-replicate script" && display "task" "$action" - cp "$user_archsetup_dir/scripts/zfs-replicate" /usr/local/bin/zfs-replicate - chmod +x /usr/local/bin/zfs-replicate + install_executable "$user_archsetup_dir/scripts/zfs-replicate" /usr/local/bin/zfs-replicate action="creating zfs-replicate systemd service" && display "task" "$action" cat << 'EOF' > /etc/systemd/system/zfs-replicate.service @@ -1853,9 +1953,16 @@ EOF run_task "enabling sanoid timer" systemctl enable sanoid.timer action="enabling weekly ZFS scrub" && display "task" "$action" - # Get pool name dynamically (usually zroot) - zfs_pool=$(zpool list -H -o name | head -1) - systemctl enable "zfs-scrub-weekly@${zfs_pool}.timer" >> "$logfile" 2>&1 || error_warn "$action" "$?" + # Enable a scrub timer for every pool (usually just zroot). No pool -> + # warn rather than enabling the malformed unit zfs-scrub-weekly@.timer. + mapfile -t scrub_units < <(zpool list -H -o name | zfs_scrub_timer_units) + if [ "${#scrub_units[@]}" -eq 0 ]; then + error_warn "$action (no ZFS pool found)" "1" + else + for scrub_unit in "${scrub_units[@]}"; do + systemctl enable "$scrub_unit" >> "$logfile" 2>&1 || error_warn "$action" "$?" + done + fi # Note: zfs-replicate.timer is NOT enabled automatically # User must set up SSH key auth to TrueNAS first, then run: @@ -1940,8 +2047,7 @@ configure_pre_pacman_snapshots() { is_zfs_root || return 0 action="installing pre-pacman snapshot script" && display "task" "$action" - cp "$user_archsetup_dir/scripts/zfs-pre-snapshot" /usr/local/bin/zfs-pre-snapshot - chmod +x /usr/local/bin/zfs-pre-snapshot + install_executable "$user_archsetup_dir/scripts/zfs-pre-snapshot" /usr/local/bin/zfs-pre-snapshot action="installing pre-pacman snapshot hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks @@ -2105,7 +2211,8 @@ UDEVEOF action="Live-Update Guard" && display "subtitle" "$action" run_task "installing the live GPU/compositor update guard" \ cp "$user_archsetup_dir/scripts/hypr-live-update-guard" /usr/local/bin/hypr-live-update-guard - chmod 755 /usr/local/bin/hypr-live-update-guard + chmod 755 /usr/local/bin/hypr-live-update-guard >> "$logfile" 2>&1 \ + || error_warn "chmod 755 hypr-live-update-guard" "$?" action="installing the live-update guard pacman hook" && display "task" "$action" mkdir -p /etc/pacman.d/hooks @@ -2141,7 +2248,8 @@ Exec = /usr/local/bin/hypr-live-update-guard AbortOnFail NeedsTargets HOOKEOF - chmod 644 /etc/pacman.d/hooks/10-hypr-live-update-guard.hook + chmod 644 /etc/pacman.d/hooks/10-hypr-live-update-guard.hook >> "$logfile" 2>&1 \ + || error_warn "chmod 644 hypr-live-update-guard.hook" "$?" } ### Display Server (conditional) @@ -2333,10 +2441,8 @@ desktop_environment() { # Enable syncthing user service by creating symlink directly # (systemctl --user fails during install - no user session bus available) action="enabling syncthing user service" && display "task" "$action" - user_systemd_dir="/home/$username/.config/systemd/user/default.target.wants" - mkdir -p "$user_systemd_dir" - ln -sf /usr/lib/systemd/user/syncthing.service "$user_systemd_dir/syncthing.service" - chown -R "$username:$username" "/home/$username/.config/systemd" + enable_user_service "$username" syncthing.service \ + /usr/lib/systemd/user/syncthing.service || error_warn "$action" "$?" # Desktop Environment Utilities @@ -2415,8 +2521,12 @@ gaming() { action="Steam" && display "subtitle" "$action" pacman_install steam - # Enable gamemode service for user - run_task "enabling gamemode for user" sudo -u "$username" systemctl --user enable gamemoded.service + # Enable gamemode user service. `systemctl --user enable` fails during + # install (no user session bus), so wire the wants symlink directly, the + # same way syncthing is enabled. + action="enabling gamemode user service" && display "task" "$action" + enable_user_service "$username" gamemoded.service \ + /usr/lib/systemd/user/gamemoded.service || error_warn "$action" "$?" } ### Zig Toolchain Pin @@ -2907,18 +3017,33 @@ tighten_efi_permissions() { } -add_nvme_early_module() { - # Add nvme module for early loading on NVMe systems - # Ensures NVMe devices are available when ZFS/other hooks try to access them - if has_nvme_drives; then - action="adding nvme to mkinitcpio MODULES for early loading" && display "task" "$action" - backup_system_file /etc/mkinitcpio.conf - if grep -q "^MODULES=()" /etc/mkinitcpio.conf; then - sed -i 's/^MODULES=()/MODULES=(nvme)/' /etc/mkinitcpio.conf - elif grep -q "^MODULES=(" /etc/mkinitcpio.conf && ! grep -q "nvme" /etc/mkinitcpio.conf; then - sed -i '/^MODULES=(/ s/)/ nvme)/' /etc/mkinitcpio.conf - fi +# Ensure nvme loads early on NVMe systems so the devices exist when ZFS/other +# hooks look for them. The presence check is scoped to the MODULES line (a +# comment or nvme_tcp elsewhere must not mask a missing entry), and the +# initramfs rebuilds whenever the file changed -- unconditionally, because the +# only nearby mkinitcpio -P used to run behind `if ! is_zfs_root`, which left +# ZFS-root machines without the early-load hardening compiled in. Conf path is +# a positional arg defaulting to the system file so the step runs against +# fixtures. +ensure_nvme_early_module() { + local conf="${1:-/etc/mkinitcpio.conf}" + has_nvme_drives || return 0 + action="adding nvme to mkinitcpio MODULES for early loading" && display "task" "$action" + backup_system_file "$conf" + if grep -q "^MODULES=()" "$conf"; then + sed -i 's/^MODULES=()/MODULES=(nvme)/' "$conf" + elif grep -q "^MODULES=(" "$conf" && \ + ! grep -qE '^MODULES=\(.*\bnvme\b' "$conf"; then + sed -i '/^MODULES=(/ s/)/ nvme)/' "$conf" + else + return 0 fi + mkinitcpio -P >> "$logfile" 2>&1 \ + || error_warn "rebuilding initramfs after adding nvme module" "$?" +} + +add_nvme_early_module() { + ensure_nvme_early_module action="removing distro and date/time from initial screen" && display "task" "$action" (: >/etc/issue) || error_warn "$action" "$?" @@ -3038,6 +3163,64 @@ trim_firmware() { } +# Merge an existing GRUB cmdline with archsetup's desired tokens. Every +# existing token survives (this is what keeps cryptdevice=/resume=/zfs= and +# any other boot-critical parameter alive); where both set the same key, +# archsetup's value wins. Args: $1 existing value, $2 desired tokens. Prints +# the merged value. +merge_grub_cmdline() { + local existing="$1" desired="$2" + local out="" tok key dtok dkey replaced + for tok in $existing; do + key="${tok%%=*}" + replaced="" + for dtok in $desired; do + dkey="${dtok%%=*}" + [ "$key" = "$dkey" ] && { replaced="$dtok"; break; } + done + out="$out ${replaced:-$tok}" + done + for dtok in $desired; do + dkey="${dtok%%=*}" + case " $out " in + *" $dkey "*|*" $dkey="*) ;; + *) out="$out $dtok" ;; + esac + done + printf '%s\n' "${out# }" +} + +# Rewrite GRUB_CMDLINE_LINUX_DEFAULT in $1 (default /etc/default/grub) as the +# merge of its current value and archsetup's tokens. The old code replaced the +# whole line with a fixed string, which dropped a base install's boot-critical +# parameters and let grub-mkconfig bake an unbootable config. A safety check +# refuses to write if any existing token's key would vanish from the merge. +update_grub_cmdline() { + local file="${1:-/etc/default/grub}" + local desired="rw loglevel=2 rd.systemd.show_status=auto rd.udev.log_level=2 nvme.noacpi=1 mem_sleep_default=deep nowatchdog random.trust_cpu=off quiet splash" + local existing merged tok key + existing=$(sed -n 's/^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT="\{0,1\}\([^"]*\)"\{0,1\}[[:space:]]*$/\1/p' "$file" | head -1) + merged=$(merge_grub_cmdline "$existing" "$desired") + for tok in $existing; do + key="${tok%%=*}" + case " $merged " in + *" $key "*|*" $key="*) ;; + *) error_warn "GRUB cmdline merge would drop '$tok'; leaving $file untouched" "1" + return 1 ;; + esac + done + if grep -q "^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=" "$file"; then + awk -v v="$merged" ' + /^[[:space:]]*GRUB_CMDLINE_LINUX_DEFAULT=/ { + print "GRUB_CMDLINE_LINUX_DEFAULT=\"" v "\""; next + } + { print }' "$file" > "$file.archsetup-tmp" \ + && mv "$file.archsetup-tmp" "$file" + else + printf 'GRUB_CMDLINE_LINUX_DEFAULT="%s"\n' "$merged" >> "$file" + fi +} + configure_grub() { # GRUB: reset timeouts, adjust log levels, larger menu for HiDPI screens, and show splashscreen # Note: nvme.noacpi=1 disables NVMe ACPI power management to prevent freezes on some drives. @@ -3046,12 +3229,13 @@ configure_grub() { action="configuring boot menu for silence and bootsplash" && display "task" "$action" if [ -f /etc/default/grub ]; then action="resetting timeouts and adjusting log levels on grub boot" && display "task" "$action" + backup_system_file /etc/default/grub sed -i "s/.*GRUB_TIMEOUT=.*/GRUB_TIMEOUT=2/g" /etc/default/grub sed -i "s/.*GRUB_DEFAULT=.*/GRUB_DEFAULT=0/g" /etc/default/grub sed -i 's/.*GRUB_TERMINAL_OUTPUT=console/GRUB_TERMINAL_OUTPUT=gfxterm/' /etc/default/grub sed -i 's/.*GRUB_GFXMODE=auto/GRUB_GFXMODE=1024x768/' /etc/default/grub sed -i "s/.*GRUB_RECORDFAIL_TIMEOUT=.*/GRUB_RECORDFAIL_TIMEOUT=2/g" /etc/default/grub - sed -i "s/.*GRUB_CMDLINE_LINUX_DEFAULT=.*/GRUB_CMDLINE_LINUX_DEFAULT=\"rw loglevel=2 rd.systemd.show_status=auto rd.udev.log_level=2 nvme.noacpi=1 mem_sleep_default=deep nowatchdog random.trust_cpu=off quiet splash\"/g" /etc/default/grub + update_grub_cmdline /etc/default/grub fi # Regenerate GRUB config after all modifications diff --git a/docs/design/2026-07-19-sentry-code-findings.org b/docs/design/2026-07-19-sentry-code-findings.org index 3816ebc..add1829 100644 --- a/docs/design/2026-07-19-sentry-code-findings.org +++ b/docs/design/2026-07-19-sentry-code-findings.org @@ -26,9 +26,9 @@ Each fire takes the next uncovered slice. Mark done as fires complete. - [X] S2 — archsetup installer, lines ~1050-2100 (post-chroot system config: locale, mirrors, user, dotfiles/stow, AUR, services, snapshot tooling, WM packages). NB still NO partitioning/mkfs/mount in this range either — the disk path is in S3 or delegated to a separate tool. - [X] S3 — archsetup installer, lines ~2100-3124 (desktop/WM, gaming, boot_ux: mkinitcpio/GRUB, finalize). RESOLVED: archsetup does NO partitioning/mkfs/mount anywhere in the file — it is a post-install config layer; disk setup is upstream (archinstall/separate tool). - [X] S4 — scripts/*.sh production scripts (audit-packages, cmail-setup-finish, post-install, import-wireguard, normalize-notify-sounds, generate-palette, easyeffects, games, setup-chess) -- [ ] S5 — scripts/testing/ framework + lib (run-test, vm-utils, validation, logging, network-diagnostics, testinfra, run-maint*, create-base-vm, cleanup, debug, setup-testing-env) -- [ ] S6 — Python: gen_tokens + test suites (refactor lens; test-quality per testing.md) -- [ ] S7 — elisp (gallery-widget.el) +- [X] S5 — scripts/testing/ framework + lib (run-test, vm-utils, validation, logging, network-diagnostics, testinfra, run-maint*, create-base-vm, cleanup, debug, setup-testing-env) +- [X] S6 — Python: gen_tokens + test suites (refactor lens; test-quality per testing.md) +- [X] S7 — elisp (gallery-widget.el) * Findings @@ -172,3 +172,79 @@ info/ok/skip/warn/err printf helpers re-defined in setup-chess.sh:61-65, cmail-s Per-file: audit-packages clean; cmail 1 (Major); post-install clean (deliberate best-effort); import-wireguard 1 (Minor, key handling itself correct); normalize-notify 1 (Minor); games clean; setup-chess 2 refactors; generate-palette clean; easyeffects clean. S4 verdict: 3 bugs (1 Major, 2 Minor), 2 refactors (2 Low). Plus a SECURITY item escalated to the approval queue (tracked WireGuard private keys). + +** S5 — scripts/testing/ framework + lib (speedrun audit, 2026-07-20 ~16:00) + +*** [BUG Major] scripts/testing/run-net-scenarios.sh:103 — diagnose-check failure is printed but never counted, run exits green +The scenario_diagnose_expect branch prints fail "$name: diagnose did NOT name it" but does not exit 1 (unlike the break and assert branches). Because the whole scenario runs inside ( ... ) || fails=$((fails+1)), a subshell whose only failure is the diagnose check still exits 0, so fails is not incremented and the script prints "all scenarios passed" and exit 0. A regression where net doctor can no longer identify a fault it is supposed to name is reported as a visible FAIL line yet a green exit code, so CI/automated callers treat the run as passing. Fix: make the diagnose-else branch force a non-zero subshell exit (exit 1 like the other two checks). First-draft harness per its own header — reviewed, not yet relied on. + +*** [BUG Minor] scripts/testing/debug-vm.sh:49 — hardcoded base-disk name ignores FS_PROFILE +BASE_DISK is hardcoded to the btrfs image archsetup-base.qcow2, while init_vm_paths (line 56) already computes the profile-correct DISK_PATH (archsetup-base-zfs.qcow2 for FS_PROFILE=zfs). FS_PROFILE=zfs ./debug-vm.sh boots the btrfs base or fatals "Base disk not found." Fix: use DISK_PATH from init_vm_paths. Debug-only tool, zfs path only. + +*** [BUG Minor] scripts/testing/lib/vm-utils.sh:284 — snapshot restore races the force-killed QEMU's qcow2 lock +kill_qemu sends kill -9 and deletes the PID file without waiting for reaping, so vm_is_running reports false at once. In the force-kill fallback, restore_snapshot then runs qemu-img snapshot -a while the dying qemu may still hold the qcow2 lock; qemu-img refuses a locked image, the restore fails, and in the trap it is swallowed by || true, leaving the base image dirty with no signal. Only bites on ACPI-powerdown timeout. Fix: wait for the killed PID (or poll /proc/$pid) before returning. + +*** [BUG Minor] scripts/testing/lib/vm-utils.sh:69 — runtime paths not profile-suffixed, parallel btrfs+zfs runs collide +init_vm_paths suffixes DISK_PATH and OVMF_VARS by FS_PROFILE but leaves PID_FILE, MONITOR_SOCK, SERIAL_LOG fixed. A concurrent btrfs and zfs run shares those three: the second run's stop_qemu/vm_is_running reads the first run's PID file and can shutdown/kill the other VM; both truncate the same serial log. The default SSH_PORT check partially masks it. Fix: suffix these three like DISK_PATH. + +*** [BUG Minor] scripts/testing/run-test.sh:287 — ARCHSETUP_EXIT_CODE never holds the installer's real exit status +The variable is set from a completion-marker grep, not archsetup's real exit code — the installer is launched detached (setsid ... &) and runs with set -e off, so it can error mid-run, continue, reach the end, and still write the marker. The report prints "ArchSetup Exit Code: 0" and the final gate treats that as success. Testinfra is the real backstop, so a broken install is still caught, but the reported code is misleading. Same pattern run-test-baremetal.sh:234. Fix: rename (ARCHSETUP_COMPLETED) and/or capture the true exit status via a file. + +*** [REFACTOR Medium] scripts/testing/run-test.sh + run-test-baremetal.sh — large-scale duplication of the run/poll/report skeleton +The two runners duplicate the git-bundle transfer, the 30s MAX_POLLS loop, the completion-marker exit derivation, the Testinfra invocation, and the near-identical report heredoc — and have drifted (VM: setsid launch + copy_to_vm/copy_from_vm helpers; baremetal: nohup + hand-rolled sshpass scp). Extract the shared poll-and-report core into a sourced helper so baremetal inherits the sturdier launch/copy paths. + +*** [REFACTOR Low] scripts/testing/run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 — duplicated scenario plumbing beyond the acknowledged helper trio +_scenario_var, _validate_scenario, and the mexec/mmaint/mfix/massert_metric helpers are byte-for-byte duplicated. The transport-specific trio (ssh vs nspawn) is defensibly duplicated, but _scenario_var and _validate_scenario are transport-independent pure functions that could live in a sourced lib/maint-scenario.sh. run_scenario is also nearly identical. + +*** [REFACTOR Low] scripts/testing/run-test.sh:251,265 — two different mechanisms to detect the same process +The launch check uses pgrep -f 'bash archsetup' (251) and the poll loop uses ps aux | grep '[b]ash archsetup' (265) for the identical liveness question. Use one mechanism to remove a maintenance trap. + +S5 verdict: 5 bugs (1 Major, 4 Minor), 3 refactors (1 Medium, 2 Low). No syntax errors; temp-file traps, snapshot-stopped guards, mfix PIPESTATUS, and the errexit-off window handled correctly. + +** S6 — Python: gen_tokens + test suites (speedrun audit, 2026-07-20 ~16:00) + +*** [TEST-QUALITY High] tests/installer-steps/test_pacman_hook_order.py:20 — ordering "invariant" is a tautology that never touches the file +test_safety_hooks_precede_mkinitcpio_removal claims to guard that 05-zfs-snapshot and 10-hypr-live-update-guard hooks are created before 60-mkinitcpio-remove. But the two assertLess calls compare two string literals ("05..." < "60...") — a constant ASCII fact always true regardless of file content. The ordering is never verified; the test would still pass if the hooks appeared after the removal or the removal moved above them. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). Matters because a real reorder can remove the current initramfs without a rebuild (unbootable) and this test would ship it green. + +*** [BUG Minor] scripts/testing/tests/test_desktop.py:96 — shell glob passed to `test -S` breaks on zero or multiple compositor sockets +test_hyprland_socket runs host.run("test -S /tmp/hypr/*/.socket.sock"). The remote shell expands the glob: zero matches leaves the literal pattern (rc 1), two+ instances pass multiple args (rc 2 "too many arguments"). Either way the assertion fails for a reason unrelated to socket existence. Masked today because the test gates on compositor_running, which the headless VM never satisfies (always skips). Fix: resolve one socket first (find /tmp/hypr -name .socket.sock -type s | head -1). + +*** [TEST-QUALITY Low] tests/gallery-tokens/test_gen_tokens.py:181 — degenerate-marker test asserts properties too weak to notice garbled output +test_start_marker_on_final_line_without_newline feeds "keep\n<START> <END>" and asserts only startswith("keep\n"), END present, "X" present — all hold, but the actual output is malformed (duplicated start marker, content spliced mid-line). Passes on broken output. The input can't occur in real HTML (markers always on their own newline-terminated lines), so severity is low; tighten to the exact expected string or drop as impossible. + +*** [REFACTOR Low] docs/prototypes/gen_tokens.py:78 — four emitters repeat the section-iteration skeleton +emit_web_css, emit_waybar_gtk, emit_elisp each walk COLOR_SECTIONS then glow (resolving through resolve_color), then font/timing for two of three. A generator yielding resolved (name, color_value) pairs with each emitter supplying its formatter would remove the triplicated traversal. Readability only — divergences are load-bearing (waybar's font/timing omission is deliberate); keep them visible. + +S6 verdict: 1 bug (Minor), 2 test-quality (1 High, 1 Low), 1 refactor (Low). gen_tokens.py has no correctness bugs; the sed-extract unit tiers import real function bodies, fake only at the system boundary, assert behavior not prose, carry N/B/E coverage, no shared state or hardcoded dates. Testinfra tiers sound. + +** S7 — elisp: gallery-widget.el + gallery-tokens.el (speedrun audit, 2026-07-20 ~16:00) + +*** [BUG Minor] docs/prototypes/gallery-widget.el:139 — readout renders raw value while the needle clamps +The needle angle is clamped 0-100 in gallery-widget--needle-angle, but the readout at line 139 uses (round value) on the unclamped input. Value 150 draws the needle pinned at +60 degrees (x2=82.64) while the text reads "150%" — confirmed live. The two halves disagree at any out-of-range input. Fix: compute a single clamped value once and format both the angle and the readout from it. + +*** [BUG Minor] docs/prototypes/gallery-widget.el:69 — cl-loop used without (require 'cl-lib) +gallery-widget--node calls cl-loop, but the file requires only svg and dom. Works today only via cl-loop's autoload cookie; bites on byte-compile in a context that macroexpands cl-loop without cl-lib, or if a future cl- macro isn't autoloaded (cold-load void-function). Fix: add (require 'cl-lib). + +*** [BUG Minor] docs/prototypes/gallery-widget.el:29 — file-name-directory on a possibly-nil path +The token load computes its directory from (or load-file-name buffer-file-name); both are nil when the form is eval'd outside a load and outside a file buffer, so file-name-directory receives nil and errors. Doesn't bite in the normal load/require path or the test harness, but makes interactive re-eval fragile. Fix: fall back to default-directory when neither is set. + +*** [REFACTOR Medium] docs/prototypes/gallery-widget.el:95,136 — hardcoded SVG path strings duplicate the geometry the constants encode +The arc path "M 1 48 A 47 47 0 0 1 95 48" and hub path "M 44 48 A 4 4 0 0 1 52 48 Z" bake in cx=48, cy=48, radius 47, hub radius 4 — the same values expressed numerically elsewhere and passed to --polar for ticks/needle. Change cx/cy/radii and ticks/needle move while arc/hub silently stay put (dial desyncs, no error). Derive the arc endpoints and hub path from cx/cy/radius via the existing --polar/--fmt machinery, or name the magic numbers as shared defconsts. + +*** [REFACTOR Low] docs/prototypes/gallery-widget.el:72,84 — reliance on the private svg--append internal +gallery-widget--node (72) and the defs/filter insertion (84) call svg--append, a double-dash private of svg.el with no cross-version stability guarantee. Low severity (prototype pinned to a known Emacs); prefer public svg.el builders or isolate the private call behind the single --node helper. + +*** [TEST-QUALITY Medium] tests/gallery-widgets/test-gallery-widget.el:77 — obtuse tick count via split-string + cl-count-if :start 1 +The "exactly three ticks" assertion splits on class="tick" then cl-count-if with an always-true predicate and :start 1 to count length-minus-one — a coincidence of split semantics, not a match count. Brittle: an empty leading/trailing segment or a stray class="tick" substring elsewhere throws it off. Replace with a direct occurrence count. + +*** [TEST-QUALITY Medium] tests/gallery-widgets/test-gallery-widget.el:47 — clamp coverage tests the angle helper but never the rendered readout +The boundary test asserts gallery-widget--needle-angle clamps -5 and 150, but nothing asserts the full gauge's readout at an out-of-range value — exactly why the readout/needle mismatch (S7 bug 1) ships green. Add a gauge-level boundary case checking both the needle endpoint and the readout string at an out-of-range input. + +*** [TEST-QUALITY Low] tests/gallery-widgets/test-gallery-widget.el:159 — output helper gallery-widget-write-svg is untested +gallery-widget-svg-string is exercised indirectly, but gallery-widget-write-svg has no coverage. A Normal case (write to temp, assert file exists, head is "<svg", return value equals the path) closes the last uncovered public entry point. + +S7 verdict: 3 bugs (3 Minor), 2 refactors (1 Medium, 1 Low), 3 test-quality (2 Medium, 1 Low). No Critical/Major; SVG geometry, needle math, token lookup, error handling otherwise sound; all 10 existing tests pass. + +** Audit complete (S1-S7) + +Installer (S1-S3): 16 bugs, 10 refactors. Scripts (S4): 3 bugs, 2 refactors + WireGuard secrets (remediated). Test framework (S5): 5 bugs, 3 refactors. Python (S6): 1 bug, 2 test-quality, 1 refactor. Elisp (S7): 3 bugs, 2 refactors, 3 test-quality. All slices covered. Speedrun triaged S1-S4 bugs into tasks (chpasswd, df, run_step, sudoers, wireguard, idempotency, chmod/cp, normalize-notify shipped; GRUB-overwrite + others filed). S5-S7 triaged into the tasks below. diff --git a/docs/prototypes/gallery-widget.el b/docs/prototypes/gallery-widget.el index 5d69148..5a80e59 100644 --- a/docs/prototypes/gallery-widget.el +++ b/docs/prototypes/gallery-widget.el @@ -24,9 +24,16 @@ (require 'svg) (require 'dom) +(require 'cl-lib) -(load (expand-file-name "gallery-tokens.el" - (file-name-directory (or load-file-name buffer-file-name))) +(defun gallery-widget--source-dir () + "Directory holding this module's source (and gallery-tokens.el). +Falls back to `default-directory' when evaluated outside a load and +outside a file buffer, where both location variables are nil." + (let ((src (or load-file-name buffer-file-name))) + (if src (file-name-directory src) default-directory))) + +(load (expand-file-name "gallery-tokens.el" (gallery-widget--source-dir)) nil t) (defun gallery-widget-token (name) @@ -43,11 +50,17 @@ a typo'd token must fail loudly, not render black." (defconst gallery-widget--gauge-max-deg 60.0 "Needle angle at value 100.") -(defun gallery-widget--needle-angle (value) - "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees." +(defun gallery-widget--clamp-value (value) + "Validate VALUE is a number and clamp it to the gauge's 0-100 range. +One clamp shared by the needle angle and the readout, so the two halves +of the widget can never disagree at an out-of-range input." (unless (numberp value) (error "Gauge value must be a number, got %S" value)) - (let ((v (min 100.0 (max 0.0 (float value))))) + (min 100.0 (max 0.0 (float value)))) + +(defun gallery-widget--needle-angle (value) + "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees." + (let ((v (gallery-widget--clamp-value value))) (+ gallery-widget--gauge-min-deg (* (/ v 100.0) (- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg))))) @@ -77,6 +90,8 @@ Positive angles swing right, matching the gauge's needle travel." Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its bottom center, 40px needle sweeping -60..+60 degrees, readout below." (let* ((w 96) (h 64) (cx 48.0) (cy 48.0) + ;; clamp once; the angle and the readout render the same value + (value (gallery-widget--clamp-value value)) (angle (gallery-widget--needle-angle value)) (tip (gallery-widget--polar cx cy 40.0 angle)) (svg (svg-create w h :viewBox (format "0 0 %d %d" w h)))) diff --git a/scripts/cmail-setup-finish.sh b/scripts/cmail-setup-finish.sh index 7f9d3fc..949023f 100755 --- a/scripts/cmail-setup-finish.sh +++ b/scripts/cmail-setup-finish.sh @@ -30,6 +30,15 @@ err() { printf 'error: %s\n' "$*" >&2; exit 1; } info() { printf '==> %s\n' "$*"; } ok() { printf ' %s\n' "$*"; } +# Decrypt $1 to $2 with 0600 from the moment of creation. gpg writes its output +# at the process umask (often 0644), so a bare decrypt leaves the plaintext +# world-readable until the chmod on the next line. The 0077 umask subshell closes +# that window; the chmod stays to tighten a looser file left by an earlier run. +decrypt_to_secure() { + ( umask 077; gpg --quiet --yes --decrypt --output "$2" "$1" ) + chmod 600 "$2" +} + # 1. Pre-reqs command -v protonmail-bridge >/dev/null 2>&1 \ || err "protonmail-bridge not found in PATH — install via archsetup first" @@ -49,8 +58,7 @@ cmailpass_enc="$HOME/.config/.cmailpass.gpg" # 2. Decrypt cmailpass info "decrypting $cmailpass_enc" cmailpass_plain="$HOME/.config/.cmailpass" -gpg --quiet --yes --decrypt --output "$cmailpass_plain" "$cmailpass_enc" -chmod 600 "$cmailpass_plain" +decrypt_to_secure "$cmailpass_enc" "$cmailpass_plain" ok "wrote $cmailpass_plain (mode 0600)" # 3. Bridge cert diff --git a/scripts/import-wireguard-configs.sh b/scripts/import-wireguard-configs.sh index 9e42033..e1db5a6 100755 --- a/scripts/import-wireguard-configs.sh +++ b/scripts/import-wireguard-configs.sh @@ -55,11 +55,13 @@ for conf in "$dir"/*.conf; do echo " $out" >&2 exit 1 fi + # nmcli import auto-activates a full-tunnel (0.0.0.0/0) profile. Bring it + # down FIRST, before the rename/modify that could fail under set -e, so a + # failed modify can never leave a live unasked-for VPN up. A profile that + # didn't activate makes this a harmless no-op. + nmcli connection down "$uuid" >/dev/null 2>&1 || true nmcli connection modify "$uuid" connection.id "$name" \ connection.autoconnect no - # nmcli import auto-activates; bring it back down so importing never leaves - # a tunnel up. A profile that didn't activate makes this a harmless no-op. - nmcli connection down "$uuid" >/dev/null 2>&1 || true echo "imported: $name (inactive, autoconnect off, iface wgpvpn)" done [ "$found" = 1 ] || { echo "no .conf files in $dir" >&2; exit 1; } diff --git a/scripts/normalize-notify-sounds.sh b/scripts/normalize-notify-sounds.sh index 72c4c33..7dffbbc 100755 --- a/scripts/normalize-notify-sounds.sh +++ b/scripts/normalize-notify-sounds.sh @@ -28,6 +28,12 @@ shopt -s nullglob files=("$SOUND_DIR"/*.ogg) (( ${#files[@]} )) || { echo "No .ogg files in $SOUND_DIR" >&2; exit 1; } +# Clean up the in-flight temp file on any exit (a failed encode under set -e +# aborts mid-loop, so the trap is what stops the temp from leaking). +tmp="" +cleanup() { [ -n "${tmp:-}" ] && rm -f "$tmp"; return 0; } +trap cleanup EXIT + for f in "${files[@]}"; do mean=$(ffmpeg -hide_banner -nostats -i "$f" -af volumedetect -f null /dev/null 2>&1 \ | grep -oP 'mean_volume: \K[-0-9.]+' || true) @@ -36,14 +42,22 @@ for f in "${files[@]}"; do continue fi gain=$(awk -v t="$TARGET_DB" -v m="$mean" 'BEGIN { printf "%.1f", t - m }') - tmp=$(mktemp --suffix=.ogg) + # Resolve the real target (SOUND_DIR is often the stow-symlinked ~/.local + # copy) and stage the temp beside it, so the mv is atomic on the same + # filesystem and replacing the real file leaves the stow symlink pointing + # at it. A truncate-in-place (cat > "$f") would have corrupted the tracked + # file if the encode produced a short or empty output. + target=$(readlink -f "$f") + tmp=$(mktemp --suffix=.ogg --tmpdir="$(dirname "$target")") ffmpeg -hide_banner -loglevel error -y -i "$f" \ -af "volume=${gain}dB" -c:a libvorbis -q:a 6 "$tmp" - # Write through the file rather than mv over it: when SOUND_DIR is the - # stow-symlinked ~/.local copy, mv would replace the symlink with a real - # file and decouple it from the repo. cat preserves the symlink target. - cat "$tmp" > "$f" - rm -f "$tmp" + if [ ! -s "$tmp" ]; then + echo "skip (empty re-encode): $f" >&2 + rm -f "$tmp"; tmp="" + continue + fi + mv -f "$tmp" "$target" + tmp="" printf "%-14s mean %7s dB gain %+6s dB -> target %s dB\n" \ "$(basename "$f")" "$mean" "$gain" "$TARGET_DB" done diff --git a/scripts/testing/debug-vm.sh b/scripts/testing/debug-vm.sh index b0fa2b9..d05ed59 100755 --- a/scripts/testing/debug-vm.sh +++ b/scripts/testing/debug-vm.sh @@ -46,7 +46,6 @@ fi # Configuration TIMESTAMP=$(date +'%Y%m%d-%H%M%S') VM_IMAGES_DIR="$PROJECT_ROOT/vm-images" -BASE_DISK="$VM_IMAGES_DIR/archsetup-base.qcow2" ROOT_PASSWORD="archsetup" OVERLAY_DISK="" @@ -54,6 +53,9 @@ OVERLAY_DISK="" LOGFILE="/tmp/debug-vm-$TIMESTAMP.log" init_logging "$LOGFILE" init_vm_paths "$VM_IMAGES_DIR" +# The profile-correct base image comes from init_vm_paths; a hardcoded +# archsetup-base.qcow2 booted the btrfs base under FS_PROFILE=zfs. +BASE_DISK="$DISK_PATH" cleanup_debug() { if declare -f vm_is_running >/dev/null 2>&1 && vm_is_running; then diff --git a/scripts/testing/lib/vm-utils.sh b/scripts/testing/lib/vm-utils.sh index b85e773..b6686e9 100755 --- a/scripts/testing/lib/vm-utils.sh +++ b/scripts/testing/lib/vm-utils.sh @@ -66,9 +66,12 @@ init_vm_paths() { # let a zfs run's ZFSBootMenu entries clobber the btrfs GRUB entry, leaving # the btrfs base unbootable (no removable ESP fallback to recover from). OVMF_VARS="$VM_IMAGES_DIR/OVMF_VARS${img_suffix}.fd" - PID_FILE="$VM_IMAGES_DIR/qemu.pid" - MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor.sock" - SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial.log" + # Runtime paths carry the same suffix: a concurrent btrfs and zfs run + # sharing one PID file let the second run's stop logic kill the first + # run's VM, and both truncated the same serial log. + PID_FILE="$VM_IMAGES_DIR/qemu${img_suffix}.pid" + MONITOR_SOCK="$VM_IMAGES_DIR/qemu-monitor${img_suffix}.sock" + SERIAL_LOG="$VM_IMAGES_DIR/qemu-serial${img_suffix}.log" mkdir -p "$VM_IMAGES_DIR" } @@ -287,6 +290,18 @@ kill_qemu() { pid=$(cat "$PID_FILE" 2>/dev/null) if [ -n "$pid" ]; then kill -9 "$pid" 2>/dev/null || true + # Wait for the process to actually die before returning: the + # force-kill fallback is followed by restore_snapshot, and + # qemu-img refuses a qcow2 the dying qemu still holds locked. + # `wait` reaps it when it's our child; otherwise poll until the + # /proc entry is gone or a zombie (fds released either way). + wait "$pid" 2>/dev/null || true + local i state + for i in $(seq 1 50); do + state=$(awk '{print $3}' "/proc/$pid/stat" 2>/dev/null) || state="" + [ -z "$state" ] || [ "$state" = "Z" ] && break + sleep 0.1 + done fi fi _cleanup_qemu_files diff --git a/scripts/testing/run-net-scenarios.sh b/scripts/testing/run-net-scenarios.sh index 7197e37..8d27438 100755 --- a/scripts/testing/run-net-scenarios.sh +++ b/scripts/testing/run-net-scenarios.sh @@ -100,13 +100,19 @@ for f in "${S_FILES[@]}"; do esac echo "== $name — $SCENARIO_DESC" scenario_break || { fail "$name: break failed"; exit 1; } + # A diagnose miss still runs the fix + assert (the repair result is + # worth having either way) but must fail the scenario: rc carries it to + # the subshell exit so the run can't report green over a diagnosis + # regression. + rc=0 if declare -F scenario_diagnose_expect >/dev/null; then if scenario_diagnose_expect; then pass "$name: diagnose named it" - else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; fi + else fail "$name: diagnose did NOT name it (inspect net doctor --json)"; rc=1; fi fi scenario_fix || info "$name: net doctor --fix returned non-zero" if scenario_assert; then pass "$name: repaired" else fail "$name: NOT repaired"; exit 1; fi + exit "$rc" ) || fails=$((fails + 1)) done diff --git a/scripts/testing/run-test-baremetal.sh b/scripts/testing/run-test-baremetal.sh index d22c424..d837389 100755 --- a/scripts/testing/run-test-baremetal.sh +++ b/scripts/testing/run-test-baremetal.sh @@ -228,14 +228,17 @@ if ! $VALIDATE_ONLY; then if [ $POLL_COUNT -ge $MAX_POLLS ]; then error "ArchSetup timed out after 90 minutes" - ARCHSETUP_EXIT_CODE=124 + ARCHSETUP_COMPLETED=timeout else - step "Retrieving archsetup exit status" + # The installer runs detached with set -e off, so its real exit + # status is unavailable; the marker only proves the script reached + # its last line. Testinfra is the pass/fail authority. + step "Checking for the archsetup completion marker" if ssh_cmd "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null"; then - ARCHSETUP_EXIT_CODE=0 + ARCHSETUP_COMPLETED=yes success "ArchSetup completed successfully" else - ARCHSETUP_EXIT_CODE=1 + ARCHSETUP_COMPLETED=no error "ArchSetup may have encountered errors" fi fi @@ -259,7 +262,7 @@ if ! $VALIDATE_ONLY; then capture_post_install_state "$TEST_RESULTS_DIR" else info "Skipping archsetup (--validate-only)" - ARCHSETUP_EXIT_CODE=0 + ARCHSETUP_COMPLETED=yes mkdir -p "$TEST_RESULTS_DIR/pre-install" "$TEST_RESULTS_DIR/post-install" fi @@ -302,7 +305,7 @@ Target: $TARGET_HOST Test Method: Bare Metal ZFS Results: - ArchSetup Exit Code: $ARCHSETUP_EXIT_CODE + ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code) Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi) Validation Summary: @@ -335,7 +338,7 @@ fi # Final summary section "Test Complete" -if [ "$ARCHSETUP_EXIT_CODE" -eq 0 ] && $TEST_PASSED; then +if [ "$ARCHSETUP_COMPLETED" = "yes" ] && $TEST_PASSED; then success "TEST PASSED" exit 0 else diff --git a/scripts/testing/run-test.sh b/scripts/testing/run-test.sh index f962df3..a5c3691 100755 --- a/scripts/testing/run-test.sh +++ b/scripts/testing/run-test.sh @@ -280,15 +280,17 @@ done if [ $POLL_COUNT -ge $MAX_POLLS ]; then error "ArchSetup timed out after 150 minutes" - ARCHSETUP_EXIT_CODE=124 + ARCHSETUP_COMPLETED=timeout else - # Get exit code from the remote log - step "Retrieving archsetup exit status..." - ARCHSETUP_EXIT_CODE=$(vm_exec "$ROOT_PASSWORD" \ - "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null && echo 0 || echo 1" \ + # The installer runs detached with set -e off, so its real exit status + # is unavailable here; the completion marker only proves the script ran + # to its last line. Testinfra below is the actual pass/fail authority. + step "Checking for the archsetup completion marker..." + ARCHSETUP_COMPLETED=$(vm_exec "$ROOT_PASSWORD" \ + "grep -q 'ARCHSETUP_EXECUTION_COMPLETE' /var/log/archsetup-*.log 2>/dev/null && echo yes || echo no" \ 2>/dev/null) - if [ "$ARCHSETUP_EXIT_CODE" = "0" ]; then + if [ "$ARCHSETUP_COMPLETED" = "yes" ]; then success "ArchSetup completed successfully" else error "ArchSetup may have encountered errors (check logs)" @@ -370,7 +372,7 @@ VM Configuration: SSH: localhost:$SSH_PORT Results: - ArchSetup Exit Code: $ARCHSETUP_EXIT_CODE + ArchSetup Completed: $ARCHSETUP_COMPLETED (completion marker, not the installer's exit code) Validation: $(if $TEST_PASSED; then echo "PASSED"; else echo "FAILED"; fi) Validation Summary: @@ -424,7 +426,7 @@ CLEANUP_DONE=1 # Final summary section "Test Complete" -if [ "$ARCHSETUP_EXIT_CODE" = "0" ] && $TEST_PASSED; then +if [ "$ARCHSETUP_COMPLETED" = "yes" ] && $TEST_PASSED; then success "TEST PASSED" exit 0 else diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py index 468bc75..1538d6a 100644 --- a/scripts/testing/tests/test_desktop.py +++ b/scripts/testing/tests/test_desktop.py @@ -93,7 +93,13 @@ def test_hyprland_socket(host, hyprland_installed, compositor_running): pytest.skip("Hyprland not installed") if not compositor_running: pytest.skip("Hyprland not running (headless) — socket check not applicable") - assert host.run("test -S /tmp/hypr/*/.socket.sock").rc == 0 + # `test -S` on a shell glob breaks at both edges: zero matches leaves the + # literal pattern (rc 1) and several instances pass multiple args (rc 2). + # find -type s proves exactly one live socket path exists. + sock = host.run( + "find /tmp/hypr -maxdepth 2 -name .socket.sock -type s | head -1" + ).stdout.strip() + assert sock, "no Hyprland socket found under /tmp/hypr" @pytest.mark.attribution("archsetup") diff --git a/tests/cmail/test_cmail_setup.py b/tests/cmail/test_cmail_setup.py new file mode 100644 index 0000000..3330ee6 --- /dev/null +++ b/tests/cmail/test_cmail_setup.py @@ -0,0 +1,79 @@ +"""Test that cmail-setup-finish decrypts the mail password without a +world-readable window. + +gpg creates its --output file at the process umask (typically 0644), so a plain +`gpg --decrypt --output f` followed by `chmod 600 f` leaves the plaintext mail +password world-readable in the gap between the two. decrypt_to_secure wraps the +decrypt in a 0077 umask subshell so the file is 0600 from creation. + +Method: sed-extract decrypt_to_secure from scripts/cmail-setup-finish.sh, run it +with a fake gpg that (a) records the effective umask at call time and (b) writes +the --output file the way real gpg would (respecting the umask). Assert the umask +was tight during the write, and that the resulting file is 0600. + +Run from repo root: + python3 -m unittest tests.cmail.test_cmail_setup +""" + +import os +import stat +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts", "cmail-setup-finish.sh") + + +def run_decrypt(): + """Run decrypt_to_secure with a fake gpg. Returns (plain_mode, umask_str).""" + tmp = tempfile.mkdtemp() + enc = os.path.join(tmp, "in.gpg") + plain = os.path.join(tmp, "cmailpass") + umask_rec = os.path.join(tmp, "umask") + with open(enc, "w") as fh: + fh.write("ciphertext") + script = textwrap.dedent(f"""\ + UMASK_REC={umask_rec!r} + gpg() {{ + umask > "$UMASK_REC" + local out="" + while [ $# -gt 0 ]; do + case "$1" in --output) out="$2"; shift;; esac + shift + done + printf 'SECRET' > "$out" + }} + source <(sed -n '/^decrypt_to_secure() {{/,/^}}/p' {SCRIPT!r}) + decrypt_to_secure {enc!r} {plain!r} + """) + subprocess.run(["bash", "-c", script], check=True, + capture_output=True, text=True, timeout=10) + mode = stat.S_IMODE(os.stat(plain).st_mode) + with open(umask_rec) as fh: + umask_str = fh.read().strip() + return mode, umask_str + + +class CmailDecryptPermissions(unittest.TestCase): + def test_decrypt_runs_under_tight_umask(self): + # The security property: gpg writes the plaintext under a 0077 umask, so + # it is 0600 from creation, not world-readable before the chmod. + _mode, umask_str = run_decrypt() + self.assertEqual( + int(umask_str, 8), 0o077, + "the mail-password decrypt must run under a 0077 umask so the " + "plaintext is never world-readable", + ) + + def test_plaintext_is_owner_only(self): + mode, _umask = run_decrypt() + self.assertEqual(mode, 0o600, + f"cmailpass ended at {oct(mode)}, expected 0600") + self.assertFalse(mode & 0o077, + "cmailpass must not be group- or world-accessible") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/gallery-tokens/test_gen_tokens.py b/tests/gallery-tokens/test_gen_tokens.py index b9fbb50..2968119 100644 --- a/tests/gallery-tokens/test_gen_tokens.py +++ b/tests/gallery-tokens/test_gen_tokens.py @@ -181,11 +181,15 @@ class ReplaceBetweenMarkers(unittest.TestCase): def test_start_marker_on_final_line_without_newline(self): # Defensive branch: no newline after the start marker. The guard must # not let text[:si-of-newline+1] collapse to "" and wipe the prefix. + # This input cannot occur in the real HTML (markers always sit on + # their own newline-terminated lines), so the exact-string assert is a + # characterization: the prefix survives, and the reconstruction's + # duplicated start marker is pinned deliberately so any change to the + # branch surfaces here instead of passing unnoticed. src = f"keep\n{self.START} {self.END}" out = gt.replace_between_markers(src, self.START, self.END, "X") - self.assertTrue(out.startswith("keep\n")) - self.assertIn(self.END, out) - self.assertIn("X", out) + self.assertEqual( + out, f"keep\n{self.START} X\n{self.START} {self.END}") class RealTokensJson(unittest.TestCase): diff --git a/tests/gallery-widgets/test-gallery-widget.el b/tests/gallery-widgets/test-gallery-widget.el index 166cd59..c0d38b4 100644 --- a/tests/gallery-widgets/test-gallery-widget.el +++ b/tests/gallery-widgets/test-gallery-widget.el @@ -73,10 +73,12 @@ (let ((xml (test-gallery-widget--svg-string 42))) ;; arc path stroked in the wash token (should (string-match-p (format "path[^>]*stroke=\"%s\"" (gallery-widget-token 'wash)) xml)) - ;; exactly three ticks - (should (= 3 (cl-count-if (lambda (_) t) - (split-string xml "class=\"tick\"" t) - :start 1))) + ;; exactly three ticks, counted as direct occurrences (the earlier + ;; split-string arithmetic depended on omit-nulls edge behavior) + (should (= 3 (let ((n 0) (pos 0)) + (while (string-match "class=\"tick\"" xml pos) + (setq n (1+ n) pos (match-end 0))) + n))) ;; needle + hub in the amber tokens (should (string-match-p (format "class=\"needle\"[^>]*stroke=\"%s\"" (gallery-widget-token 'gold-hi)) @@ -110,5 +112,53 @@ "The readout shows a rounded integer percent, matching the web card." (should (string-match-p ">67%<" (test-gallery-widget--svg-string 66.6)))) +(ert-deftest gallery-widget-gauge-out-of-range-readout-clamps () + "Readout and needle agree at out-of-range values: both clamp. +Regression: the needle clamped to the dial ends while the readout rendered +the raw value, so a gauge fed 150 pinned the needle at +60 degrees under a +\"150%\" label." + (let ((over (test-gallery-widget--svg-string 150))) + (should (string-match-p ">100%<" over)) + (should-not (string-match-p ">150%<" over))) + (let ((under (test-gallery-widget--svg-string -5))) + (should (string-match-p ">0%<" under)) + (should-not (string-match-p ">-5%<" under)))) + +(ert-deftest gallery-widget-source-dir-load-and-fallback () + "The token-file directory resolves from load context, else default-directory. +Regression: `file-name-directory' received nil when the load form was re-evaluated +outside a load and outside a file buffer." + (let ((load-file-name "/tmp/proto/gallery-widget.el")) + (should (equal (gallery-widget--source-dir) "/tmp/proto/"))) + (with-temp-buffer + (let ((load-file-name nil) + (default-directory "/tmp/elsewhere/")) + (should (equal (gallery-widget--source-dir) "/tmp/elsewhere/"))))) + +(ert-deftest gallery-widget-write-svg-writes-file-and-returns-path () + "The output helper writes the SVG document and returns the path." + (let ((file (make-temp-file "gallery-widget-test-" nil ".svg"))) + (unwind-protect + (let ((ret (gallery-widget-write-svg + (gallery-widget-needle-gauge 42) file))) + (should (equal ret file)) + (should (file-exists-p file)) + (with-temp-buffer + (insert-file-contents file) + (should (string-match-p "\\`<svg" (buffer-string))))) + (delete-file file)))) + +(ert-deftest gallery-widget-requires-cl-lib-at-load-time () + "Loading the module alone brings in cl-lib, not just an autoload cookie. +A cold byte-compile or a changed autoload would otherwise break +`gallery-widget--node' with a void-function error on first call." + (with-temp-buffer + (let ((emacs (expand-file-name invocation-name invocation-directory)) + (widget (expand-file-name "docs/prototypes/gallery-widget.el" + test-gallery-widget--root))) + (call-process emacs nil t nil "--batch" "-l" widget + "--eval" "(princ (if (featurep 'cl-lib) \"cl-lib:yes\" \"cl-lib:no\"))") + (should (string-match-p "cl-lib:yes" (buffer-string)))))) + (provide 'test-gallery-widget) ;;; test-gallery-widget.el ends here diff --git a/tests/import-wireguard-configs/fake-nmcli b/tests/import-wireguard-configs/fake-nmcli index 45b88cd..30de62f 100644 --- a/tests/import-wireguard-configs/fake-nmcli +++ b/tests/import-wireguard-configs/fake-nmcli @@ -38,6 +38,9 @@ case "$1 $2" in "connection modify") exit "${FAKE_NMCLI_MODIFY_RC:-0}" ;; +"connection down") + exit "${FAKE_NMCLI_DOWN_RC:-0}" + ;; *) echo "fake-nmcli: unexpected args: $*" >&2 exit 99 diff --git a/tests/import-wireguard-configs/test_import_wireguard_configs.py b/tests/import-wireguard-configs/test_import_wireguard_configs.py index 0307041..45afa54 100644 --- a/tests/import-wireguard-configs/test_import_wireguard_configs.py +++ b/tests/import-wireguard-configs/test_import_wireguard_configs.py @@ -162,6 +162,28 @@ class ImportWireguardConfigs(unittest.TestCase): imports = [ln for ln in self.log_lines() if ln.startswith("connection import")] self.assertEqual(len(imports), 1) + def test_tunnel_is_brought_down_before_the_modify(self): + # nmcli import auto-activates a full-tunnel (0.0.0.0/0) profile. The + # down must run before the rename/modify so a failed modify under set -e + # can never leave a live unasked-for VPN up. + self.write_conf("USNY") + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + verbs = [ln.split()[1] for ln in self.log_lines() + if ln.startswith("connection ")] + self.assertEqual(verbs, ["import", "down", "modify"], verbs) + + def test_modify_failure_still_left_the_tunnel_down(self): + # Even when the modify fails and aborts the run, the down already ran, + # so no live tunnel survives. + self.write_conf("USNY") + r = self.run_script(env_extra={"FAKE_NMCLI_MODIFY_RC": "4"}) + self.assertNotEqual(r.returncode, 0) + verbs = [ln.split()[1] for ln in self.log_lines() + if ln.startswith("connection ")] + self.assertIn("down", verbs, "the tunnel must be downed before the modify aborts") + self.assertLess(verbs.index("down"), verbs.index("modify")) + if __name__ == "__main__": unittest.main() diff --git a/tests/installer-steps/test_check_disk_space.py b/tests/installer-steps/test_check_disk_space.py new file mode 100644 index 0000000..deec6d9 --- /dev/null +++ b/tests/installer-steps/test_check_disk_space.py @@ -0,0 +1,94 @@ +"""Test the disk-space pre-flight gate. + +Two bugs the extracted check_disk_space fixes: + +1. `df /` wraps to two lines when the source device name is long (common on a + live ISO / device-mapper root). The old `df / | awk 'NR==2 {print $4}'` + then reads the device-name line, gets an empty $4, and wrongly aborts on a + disk with plenty of space. `df -P /` forces POSIX single-line output. +2. The old code truncated KB -> GB with integer division before comparing, + biasing the gate against the user near the threshold. The check now compares + available KB against the minimum in KB directly. + +Method: sed-extract check_disk_space from the real `archsetup`, run it with a +fake df (controlled output, and wrapped output for the plain-df regression) and +assert the gate passes/aborts correctly. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_check_disk_space +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +# Fake df: single, correct POSIX line for `df -P`; a WRAPPED two-line body for +# plain `df`. If check_disk_space ever reverts to plain `df`, the wrapped body +# yields an empty Available field and the ample-space test fails -- that is the +# regression guard for bug 1. +FAKE_DF = textwrap.dedent("""\ + df() {{ + if [ "$1" = "-P" ]; then + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/root {avail} 100 {avail} 1% /" + else + printf '%s\\n' "Filesystem 1024-blocks Used Available Capacity Mounted on" + printf '%s\\n' "/dev/mapper/a-very-long-device-mapper-name-that-wraps" + printf '%s\\n' " {avail} 100 {avail} 1% /" + fi + }} +""") + + +def run_check(available_kb, min_gb=20): + """Run check_disk_space with fake df reporting available_kb free.""" + script = textwrap.dedent(f"""\ + min_disk_space_gb={min_gb} + {FAKE_DF.format(avail=available_kb)} + source <(sed -n '/^check_disk_space() {{/,/^}}/p' "{ARCHSETUP}") + check_disk_space + echo "REACHED-END" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class CheckDiskSpace(unittest.TestCase): + # min 20GB -> 20 * 1024 * 1024 = 20971520 KB + MIN_KB = 20 * 1024 * 1024 + + def test_ample_space_passes(self): + # ~95GB free; also the wrapped-df regression guard (plain df would + # yield an empty Available and wrongly abort). + r = run_check(99000000) + self.assertIn("REACHED-END", r.stdout) + self.assertIn("[OK] Disk space", r.stdout) + + def test_insufficient_space_aborts(self): + r = run_check(5 * 1024 * 1024) # 5GB + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_boundary_exact_minimum_passes(self): + r = run_check(self.MIN_KB) # exactly 20GB in KB + self.assertIn("REACHED-END", r.stdout) + + def test_boundary_one_kb_under_minimum_aborts(self): + r = run_check(self.MIN_KB - 1) + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + def test_empty_df_output_aborts(self): + # df field unparseable -> treat as zero, abort rather than crash. + r = run_check("") + self.assertIn("Insufficient disk space", r.stdout) + self.assertNotIn("REACHED-END", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_ensure_nvme_early_module.py b/tests/installer-steps/test_ensure_nvme_early_module.py new file mode 100644 index 0000000..8e3f036 --- /dev/null +++ b/tests/installer-steps/test_ensure_nvme_early_module.py @@ -0,0 +1,95 @@ +"""Test the nvme early-module step. + +Two bugs the extracted ensure_nvme_early_module fixes: + +1. The MODULES=(... nvme) edit was only compiled into the initramfs by a + mkinitcpio -P that ran behind `if ! is_zfs_root`, so on ZFS-root machines + the early-load hardening never landed. The step now rebuilds whenever it + changed the file, unconditionally. +2. The already-present check grepped the whole file for "nvme", so a comment + or an unrelated token (nvme_tcp) anywhere in mkinitcpio.conf skipped the + edit. The check now looks for the nvme word on the MODULES line only. + +Method: sed-extract ensure_nvme_early_module from the real `archsetup`, point +it at a temp mkinitcpio.conf, and fake has_nvme_drives/backup_system_file/ +mkinitcpio/display/error_warn. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_ensure_nvme_early_module +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(conf_body, has_nvme=True): + with tempfile.TemporaryDirectory() as d: + conf = os.path.join(d, "mkinitcpio.conf") + with open(conf, "w") as f: + f.write(conf_body) + script = textwrap.dedent(f"""\ + logfile=/dev/null + action="" + display() {{ :; }} + backup_system_file() {{ :; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + has_nvme_drives() {{ return {0 if has_nvme else 1}; }} + # stdout is redirected into $logfile at the call site, so the fake + # records its invocation in a side file instead. + mkinitcpio() {{ echo "MKINITCPIO $*" >> "{d}/mk.log"; }} + source <(sed -n '/^ensure_nvme_early_module() {{/,/^}}/p' "{ARCHSETUP}") + ensure_nvme_early_module "{conf}" + echo "RC=$?" + echo "CONF:[$(cat "{conf}")]" + [ -f "{d}/mk.log" ] && cat "{d}/mk.log" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class EnsureNvmeEarlyModule(unittest.TestCase): + def test_empty_modules_gets_nvme_and_rebuilds(self): + r = run("MODULES=()\nHOOKS=(base udev)\n") + self.assertIn("MODULES=(nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout, + "the initramfs must rebuild after the MODULES edit") + + def test_populated_modules_appends_nvme_and_rebuilds(self): + r = run("MODULES=(btrfs)\n") + self.assertIn("MODULES=(btrfs nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout) + + def test_nvme_already_present_no_edit_no_rebuild(self): + r = run("MODULES=(nvme)\n") + self.assertIn("MODULES=(nvme)", r.stdout) + self.assertNotIn("MODULES=(nvme nvme)", r.stdout) + self.assertNotIn("MKINITCPIO", r.stdout, + "an unchanged conf must not trigger a rebuild (resume idempotence)") + + def test_nvme_mention_elsewhere_does_not_skip_the_edit(self): + # The old whole-file grep skipped the edit when any line mentioned + # nvme; a comment must not mask a missing MODULES entry. + r = run("# early nvme notes: nvme_load=YES\nMODULES=(btrfs)\n") + self.assertIn("MODULES=(btrfs nvme)", r.stdout) + self.assertIn("MKINITCPIO -P", r.stdout) + + def test_nvme_tcp_module_does_not_count_as_nvme(self): + r = run("MODULES=(nvme_tcp)\n") + self.assertIn("MODULES=(nvme_tcp nvme)", r.stdout) + + def test_no_nvme_drives_is_a_noop(self): + r = run("MODULES=()\n", has_nvme=False) + self.assertIn("RC=0", r.stdout) + self.assertIn("CONF:[MODULES=()", r.stdout) + self.assertNotIn("MKINITCPIO", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_grub_cmdline.py b/tests/installer-steps/test_grub_cmdline.py new file mode 100644 index 0000000..8c0b5b0 --- /dev/null +++ b/tests/installer-steps/test_grub_cmdline.py @@ -0,0 +1,121 @@ +"""Test the GRUB cmdline merge. + +Bug (P2): configure_grub rewrote the whole GRUB_CMDLINE_LINUX_DEFAULT line +with a fixed string. A base install that had set cryptdevice=/resume=/zfs= +(or any other boot-critical token) lost it, and the following grub-mkconfig +baked an unbootable config. + +The fix is a merge: every pre-existing token survives, archsetup's tokens are +added, and where both set the same key archsetup's value wins. A safety +assert refuses to write if any existing token's key would vanish. + +Method: sed-extract merge_grub_cmdline (pure) and update_grub_cmdline +(file-level, run against temp grub files with a fake error_warn). + +Run from repo root: + python3 -m unittest tests.installer-steps.test_grub_cmdline +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +EXTRACT = ( + "source <(sed -n '/^merge_grub_cmdline() {{/,/^}}/p;" + "/^update_grub_cmdline() {{/,/^}}/p' \"{a}\")" +).format(a=ARCHSETUP) + + +def run(body): + script = f"logfile=/dev/null\nerror_warn() {{ echo \"WARN: $1\"; return 1; }}\n{EXTRACT}\n{body}\n" + return subprocess.run(["bash", "-c", script], + capture_output=True, text=True, timeout=10) + + +def merge(existing, desired): + r = run(f'merge_grub_cmdline "{existing}" "{desired}"') + return r.stdout.strip() + + +class MergeGrubCmdline(unittest.TestCase): + DESIRED = "rw loglevel=2 quiet splash" + + def test_empty_existing_yields_desired(self): + self.assertEqual(merge("", self.DESIRED), self.DESIRED) + + def test_boot_critical_tokens_survive(self): + out = merge("cryptdevice=UUID=abc:root resume=/dev/nvme0n1p3 root=/dev/mapper/root", + self.DESIRED) + for tok in ("cryptdevice=UUID=abc:root", "resume=/dev/nvme0n1p3", + "root=/dev/mapper/root", "loglevel=2", "quiet", "splash"): + self.assertIn(tok, out.split(), f"{tok} missing from: {out}") + + def test_same_key_desired_value_wins_once(self): + out = merge("loglevel=7 quiet", self.DESIRED).split() + self.assertIn("loglevel=2", out) + self.assertNotIn("loglevel=7", out) + self.assertEqual(out.count("loglevel=2"), 1) + self.assertEqual(out.count("quiet"), 1) + + def test_zfs_token_survives(self): + out = merge("zfs=zroot/ROOT/default", self.DESIRED).split() + self.assertIn("zfs=zroot/ROOT/default", out) + + +class UpdateGrubCmdline(unittest.TestCase): + def run_update(self, grub_body): + with tempfile.TemporaryDirectory() as d: + path = os.path.join(d, "grub") + with open(path, "w") as f: + f.write(grub_body) + r = run(f'update_grub_cmdline "{path}"; echo "RC=$?"') + with open(path) as f: + return r, f.read() + + def line(self, content): + return [ln for ln in content.splitlines() + if ln.startswith('GRUB_CMDLINE_LINUX_DEFAULT=')] + + def test_existing_tokens_preserved_in_file(self): + _, content = self.run_update(textwrap.dedent("""\ + GRUB_TIMEOUT=5 + GRUB_CMDLINE_LINUX_DEFAULT="loglevel=3 cryptdevice=UUID=abc:root resume=/dev/sda2" + GRUB_DISABLE_RECOVERY=true + """)) + lines = self.line(content) + self.assertEqual(len(lines), 1) + val = lines[0] + self.assertIn("cryptdevice=UUID=abc:root", val) + self.assertIn("resume=/dev/sda2", val) + self.assertIn("loglevel=2", val) # archsetup's value wins + self.assertNotIn("loglevel=3", val) + self.assertIn("quiet", val) + # other lines untouched + self.assertIn("GRUB_TIMEOUT=5", content) + self.assertIn("GRUB_DISABLE_RECOVERY=true", content) + + def test_quoting_stays_a_single_pair(self): + _, content = self.run_update('GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n') + val = self.line(content)[0] + self.assertEqual(val.count('"'), 2) + self.assertRegex(val, r'^GRUB_CMDLINE_LINUX_DEFAULT="[^"]*"$') + + def test_commented_line_gets_active_line_appended(self): + _, content = self.run_update('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"\n') + self.assertEqual(len(self.line(content)), 1) + self.assertIn('#GRUB_CMDLINE_LINUX_DEFAULT="quiet"', content) + + def test_idempotent_on_rerun(self): + body = 'GRUB_CMDLINE_LINUX_DEFAULT="cryptdevice=UUID=abc:root"\n' + _, once = self.run_update(body) + _, twice = self.run_update(once) + self.assertEqual(once, twice) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_idempotency_cluster.py b/tests/installer-steps/test_idempotency_cluster.py new file mode 100644 index 0000000..ecb279d --- /dev/null +++ b/tests/installer-steps/test_idempotency_cluster.py @@ -0,0 +1,119 @@ +"""Test the resume-idempotency cluster: three extracted installer helpers. + +Each fix targets a step that misbehaves on a resume re-run or with unexpected +system state: + +- crontab_append_once: the log-cleanup cron line was appended unconditionally, + so a resume re-run of essential_services stacked a duplicate line each time. + The helper appends only when no existing line matches. +- zfs_scrub_timer_units: the scrub timer used `zpool list | head -1`, which + picks an arbitrary pool when several exist and yields the malformed unit + `zfs-scrub-weekly@.timer` when none do. The helper emits one unit per pool + and nothing for no pools. +- enable_user_service: gamemode was enabled via `systemctl --user enable`, + which the script itself documents fails at install time (no user bus). The + helper creates the wants symlink directly, the pattern syncthing already uses. + +Method: sed-extract each helper from the real `archsetup` and drive it with +plain stdin/args (the first two are pure) or a temp HOME (the third). + +Run from repo root: + python3 -m unittest tests.installer-steps.test_idempotency_cluster +""" + +import os +import subprocess +import tempfile +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +ME = os.environ.get("USER") or __import__("getpass").getuser() + + +def extract(func): + return ( + "source <(sed -n '/^{f}() {{/,/^}}/p' \"{a}\")".format(f=func, a=ARCHSETUP) + ) + + +def run(func, body, stdin=None): + script = f"{extract(func)}\n{body}\n" + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + input=stdin, + ) + + +class CrontabAppendOnce(unittest.TestCase): + def call(self, existing, match, line): + body = f'crontab_append_once {match!r} {line!r}' + return run("crontab_append_once", body, stdin=existing).stdout + + def test_appends_when_absent(self): + out = self.call("", "log-cleanup", "0 12 * * * run-cleanup") + self.assertIn("0 12 * * * run-cleanup", out) + + def test_preserves_existing_and_appends(self): + out = self.call("0 0 * * * other-job\n", "log-cleanup", "0 12 * * * cleanup") + self.assertIn("other-job", out) + self.assertIn("0 12 * * * cleanup", out) + + def test_does_not_duplicate_when_present(self): + existing = "0 12 * * * $HOME/.local/bin/cron/log-cleanup\n" + out = self.call(existing, "cron/log-cleanup", "0 12 * * * dup") + self.assertNotIn("0 12 * * * dup", out) + self.assertEqual(out.count("log-cleanup"), 1) + + +class ZfsScrubTimerUnits(unittest.TestCase): + def units(self, pools_text): + out = run("zfs_scrub_timer_units", "zfs_scrub_timer_units", stdin=pools_text).stdout + return [ln for ln in out.splitlines() if ln] + + def test_single_pool(self): + self.assertEqual(self.units("zroot\n"), + ["zfs-scrub-weekly@zroot.timer"]) + + def test_no_pool_yields_no_unit(self): + self.assertEqual(self.units(""), []) + + def test_multiple_pools_each_get_a_unit(self): + self.assertEqual( + self.units("zroot\ntank\n"), + ["zfs-scrub-weekly@zroot.timer", "zfs-scrub-weekly@tank.timer"], + ) + + def test_blank_lines_are_skipped(self): + self.assertEqual(self.units("zroot\n\n"), + ["zfs-scrub-weekly@zroot.timer"]) + + +class EnableUserService(unittest.TestCase): + def test_creates_wants_symlink_in_user_config(self): + with tempfile.TemporaryDirectory() as home: + body = ( + f'enable_user_service {ME!r} gamemoded.service ' + f'/usr/lib/systemd/user/gamemoded.service {home!r}\n' + f'link="{home}/.config/systemd/user/default.target.wants/gamemoded.service"\n' + f'[ -L "$link" ] && echo "LINK=yes" || echo "LINK=no"\n' + f'echo "TARGET=$(readlink "$link")"' + ) + out = run("enable_user_service", body).stdout + self.assertIn("LINK=yes", out) + self.assertIn("TARGET=/usr/lib/systemd/user/gamemoded.service", out) + + def test_idempotent_on_rerun(self): + with tempfile.TemporaryDirectory() as home: + svc = "/usr/lib/systemd/user/gamemoded.service" + one = ( + f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n' + f'enable_user_service {ME!r} gamemoded.service {svc!r} {home!r}\n' + f'echo "RC=$?"' + ) + out = run("enable_user_service", one).stdout + self.assertIn("RC=0", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_install_executable.py b/tests/installer-steps/test_install_executable.py new file mode 100644 index 0000000..6a2ecfd --- /dev/null +++ b/tests/installer-steps/test_install_executable.py @@ -0,0 +1,66 @@ +"""Test the guarded executable install helper. + +With `set -e` off, the installer's bare `cp script /usr/local/bin/...` followed +by `chmod +x` failed silently when the source was missing or partial, leaving a +systemd service with a dead ExecStart or a pacman hook pointing at a script that +was never installed. install_executable guards the copy and the chmod, warns on +failure, and skips the chmod when the copy did not land. + +Method: sed-extract install_executable from the real `archsetup` and run it +against real temp files (no fakes needed) with a fake error_warn recorder. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_install_executable +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(src, dest): + script = textwrap.dedent(f"""\ + logfile=/dev/null + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^install_executable() {{/,/^}}/p' "{ARCHSETUP}") + install_executable "{src}" "{dest}" + echo "RC=$?" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class InstallExecutable(unittest.TestCase): + def test_copies_and_makes_executable(self): + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "myscript") + dest = os.path.join(d, "bin", "myscript") + os.mkdir(os.path.join(d, "bin")) + with open(src, "w") as f: + f.write("#!/bin/sh\necho hi\n") + r = run(src, dest) + self.assertIn("RC=0", r.stdout) + self.assertTrue(os.path.exists(dest)) + self.assertTrue(os.access(dest, os.X_OK), "dest must be executable") + self.assertNotIn("WARN", r.stdout) + + def test_missing_source_warns_and_skips_chmod(self): + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "does-not-exist") + dest = os.path.join(d, "bin", "myscript") + os.mkdir(os.path.join(d, "bin")) + r = run(src, dest) + self.assertIn("WARN: copying", r.stdout, + "a failed copy must warn, not silently continue") + self.assertNotIn("RC=0", r.stdout, "helper returns non-zero on copy failure") + self.assertFalse(os.path.exists(dest)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_pacman_hook_order.py b/tests/installer-steps/test_pacman_hook_order.py index 49acdbe..45058dd 100644 --- a/tests/installer-steps/test_pacman_hook_order.py +++ b/tests/installer-steps/test_pacman_hook_order.py @@ -1,26 +1,78 @@ -"""Regression tests for the pacman hook ordering safety invariant.""" +"""Regression tests for the pacman hook ordering safety invariant. + +pacman runs hooks in filename sort order, so the safety hooks archsetup +installs (the pre-pacman ZFS snapshot and the live-update guard, both +PreTransaction) must carry names that sort before mkinitcpio's stock +60-mkinitcpio-remove.hook. Otherwise a blocked transaction can remove the +current initramfs before the guard aborts, or after the snapshot window. + +The earlier version of this test asserted the ordering by comparing two string +literals to each other, which is constant-true and never looked at the source. +These tests extract the hook filenames the installer actually writes and +compare those, so a rename in the source flows into the assertion. +""" import os +import re import unittest REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") +MKINITCPIO_REMOVE = "60-mkinitcpio-remove.hook" # stock mkinitcpio hook name + +WRITE_RE = re.compile(r">\s*/etc/pacman\.d/hooks/(\S+\.hook)\b") + + +def written_hooks(text): + """Hook filenames the source writes (redirections into the hooks dir).""" + names = [] + for line in text.splitlines(): + if "rm -f" in line: + continue + m = WRITE_RE.search(line) + if m: + names.append(m.group(1)) + return names + class PacmanHookOrderTests(unittest.TestCase): - def test_safety_hooks_precede_mkinitcpio_removal(self): + @classmethod + def setUpClass(cls): with open(ARCHSETUP, encoding="utf-8") as source: - text = source.read() + cls.text = source.read() + cls.hooks = written_hooks(cls.text) + def hook_named(self, suffix): + matches = [h for h in self.hooks if h.endswith(suffix)] + self.assertEqual( + len(matches), 1, + f"expected exactly one written hook ending {suffix}, got {matches}") + return matches[0] + + def test_safety_hooks_sort_before_mkinitcpio_removal(self): + # The invariant pacman actually enforces: filename sort order. + for suffix in ("zfs-snapshot.hook", "hypr-live-update-guard.hook"): + name = self.hook_named(suffix) + self.assertLess( + name, MKINITCPIO_REMOVE, + f"{name} must sort before {MKINITCPIO_REMOVE} or the guard " + "runs after the initramfs removal") + + def test_every_written_hook_has_an_ordering_prefix(self): + # Ordering is deliberate; a hook without a numeric prefix sorts by + # accident of its first letter. + self.assertTrue(self.hooks, "extraction found no written hooks") + for name in self.hooks: + self.assertRegex(name, r"^\d{2}-", + f"{name} lacks the two-digit ordering prefix") + + def test_stale_unprefixed_hooks_are_cleaned_up(self): + # Migration from installs made before the numeric prefixes. + self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", self.text) self.assertIn( - "cat << 'EOF' > /etc/pacman.d/hooks/05-zfs-snapshot.hook", text) - self.assertIn( - "cat > /etc/pacman.d/hooks/10-hypr-live-update-guard.hook", text) - self.assertLess("05-zfs-snapshot.hook", "60-mkinitcpio-remove.hook") - self.assertLess("10-hypr-live-update-guard.hook", "60-mkinitcpio-remove.hook") - self.assertIn("rm -f /etc/pacman.d/hooks/zfs-snapshot.hook", text) - self.assertIn("rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", text) + "rm -f /etc/pacman.d/hooks/hypr-live-update-guard.hook", self.text) if __name__ == "__main__": diff --git a/tests/installer-steps/test_replace_sudoers_pacnew.py b/tests/installer-steps/test_replace_sudoers_pacnew.py new file mode 100644 index 0000000..8bfa914 --- /dev/null +++ b/tests/installer-steps/test_replace_sudoers_pacnew.py @@ -0,0 +1,76 @@ +"""Test the guarded sudoers.pacnew replacement. + +Bug (Minor, hard consequence): the installer did + [ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers +with no validation. A malformed pacnew replaces /etc/sudoers with a file sudo +refuses to parse, locking out privilege escalation right before the NOPASSWD +rule is appended. replace_sudoers_pacnew now runs `visudo -cf` on the pacnew +first and only copies a file that validates; a failure warns (non-fatal) and +leaves the working sudoers in place. + +Method: sed-extract replace_sudoers_pacnew from the real `archsetup`, pass a +temp pacnew + temp target (the function's positional-arg testability seam), and +drive it with a fake visudo (controlled validation result) and a fake +error_warn recorder. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_replace_sudoers_pacnew +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(*, pacnew_exists, visudo_rc): + """Run replace_sudoers_pacnew with a temp pacnew/target and fake visudo.""" + with tempfile.TemporaryDirectory() as d: + pacnew = os.path.join(d, "sudoers.pacnew") + target = os.path.join(d, "sudoers") + with open(target, "w") as f: + f.write("ORIGINAL\n") + if pacnew_exists: + with open(pacnew, "w") as f: + f.write("NEW\n") + script = textwrap.dedent(f"""\ + logfile=/dev/null + display() {{ :; }} + visudo() {{ return {visudo_rc}; }} + error_warn() {{ echo "WARN: $1"; return 1; }} + source <(sed -n '/^replace_sudoers_pacnew() {{/,/^}}/p' "{ARCHSETUP}") + replace_sudoers_pacnew "{pacnew}" "{target}" + echo "RC=$?" + echo "TARGET=[$(cat "{target}")]" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class ReplaceSudoersPacnew(unittest.TestCase): + def test_valid_pacnew_is_copied(self): + r = run(pacnew_exists=True, visudo_rc=0) + self.assertIn("TARGET=[NEW]", r.stdout) + self.assertNotIn("WARN", r.stdout) + + def test_invalid_pacnew_warns_and_keeps_original(self): + r = run(pacnew_exists=True, visudo_rc=1) + self.assertIn("WARN", r.stdout, + "a pacnew that fails visudo must warn, not clobber sudoers") + self.assertIn("TARGET=[ORIGINAL]", r.stdout, + "the working sudoers must survive a malformed pacnew") + + def test_no_pacnew_is_a_noop(self): + r = run(pacnew_exists=False, visudo_rc=0) + self.assertIn("RC=0", r.stdout) + self.assertIn("TARGET=[ORIGINAL]", r.stdout) + self.assertNotIn("WARN", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_required_software.py b/tests/installer-steps/test_required_software.py new file mode 100644 index 0000000..85b01d8 --- /dev/null +++ b/tests/installer-steps/test_required_software.py @@ -0,0 +1,58 @@ +"""Test that install_required_software declares the expected base packages. + +inetutils provides /usr/bin/ftp, which TRAMP's /ftp: method (ange-ftp) shells +out to; Arch ships no command-line ftp client by default. It must be in the +base install so every machine gets it (Craig's dirvish config has an FTP +quick-access entry that depends on it). + +Method mirrors test_orchestrators: sed-extract install_required_software from +the real `archsetup`, stub pacman_install as a recorder that echoes the package +name, run, and assert membership. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_required_software +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def required_packages(): + """Return (exit_code, [package, ...]) declared by install_required_software.""" + script = textwrap.dedent(f"""\ + display() {{ :; }} + pacman_install() {{ echo "$1"; }} + source <(sed -n '/^install_required_software() {{/,/^}}/p' "{ARCHSETUP}") + install_required_software + """) + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + return result.returncode, result.stdout.split() + + +class RequiredSoftware(unittest.TestCase): + def test_installs_inetutils_for_ftp(self): + rc, pkgs = required_packages() + self.assertEqual(rc, 0) + self.assertIn( + "inetutils", pkgs, + "inetutils (provides /usr/bin/ftp for TRAMP's /ftp: method) must be " + "in the base install", + ) + + def test_core_base_packages_present(self): + # Guard a few load-bearing entries so a bad edit to the list is caught. + rc, pkgs = required_packages() + self.assertEqual(rc, 0) + for p in ("base-devel", "git", "stow", "openssh", "curl"): + self.assertIn(p, pkgs, f"{p} dropped from the base install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_run_step.py b/tests/installer-steps/test_run_step.py new file mode 100644 index 0000000..994e8c5 --- /dev/null +++ b/tests/installer-steps/test_run_step.py @@ -0,0 +1,92 @@ +"""Test run_step's state-marker and scope behavior. + +Bug (Major): run_step marked a step complete only when its function returned 0. +But error_fatal exits the script outright, so a step function that *returns* has +already survived every fatal check -- a non-zero return can only come from a +trailing non-fatal warning (error_warn returns 1). The old code read that as a +step failure and withheld the state marker, so the step re-ran on every resume. +run_step now records the marker whenever the function returns. + +Bug (Minor): run_step's step_name/step_func leaked to global scope. + +Method: sed-extract run_step + step_completed + mark_complete from the real +`archsetup`, point state_dir at a temp dir, and drive it with fake step +functions. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_run_step +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + +EXTRACT = ( + "source <(sed -n '/^run_step() {{/,/^}}/p;" + "/^step_completed() {{/,/^}}/p;" + "/^mark_complete() {{/,/^}}/p' \"{archsetup}\")" +).format(archsetup=ARCHSETUP) + + +def run(step_body, pre_marked=False): + """Run run_step against a fake step function whose body is step_body.""" + with tempfile.TemporaryDirectory() as state_dir: + marker_setup = ( + f'mkdir -p "{state_dir}"; echo pre > "{state_dir}/mystep"\n' + if pre_marked else "" + ) + script = textwrap.dedent(f"""\ + state_dir="{state_dir}" + {marker_setup} + mystep() {{ + {step_body} + }} + {EXTRACT} + run_step "mystep" "mystep" + rc=$? + echo "RUN_STEP_RC=$rc" + # marker present? + [ -f "$state_dir/mystep" ] && echo "MARKED=yes" || echo "MARKED=no" + # locals must not leak + echo "LEAK_step_name=[${{step_name:-}}]" + echo "LEAK_step_func=[${{step_func:-}}]" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class RunStep(unittest.TestCase): + def test_success_marks_complete(self): + r = run("return 0") + self.assertIn("RUN_STEP_RC=0", r.stdout) + self.assertIn("MARKED=yes", r.stdout) + + def test_nonfatal_warning_return_still_marks_complete(self): + # The bug fix: a step ending in a non-fatal warning returns 1 but has + # done its work; it must be marked so resume does not re-run it. + r = run("return 1") + self.assertIn("MARKED=yes", r.stdout, + "a returned-non-zero step must still record its marker") + self.assertIn("RUN_STEP_RC=0", r.stdout, + "run_step returns 0 so the main loop treats it as handled") + + def test_already_completed_skips_and_does_not_rerun(self): + # Body would create a sentinel if it ran; pre-marked step must skip it. + r = run("echo RAN-BODY", pre_marked=True) + self.assertIn("Skipping", r.stdout) + self.assertNotIn("RAN-BODY", r.stdout) + + def test_locals_do_not_leak_to_global_scope(self): + r = run("return 0") + self.assertIn("LEAK_step_name=[]", r.stdout) + self.assertIn("LEAK_step_func=[]", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_set_user_password.py b/tests/installer-steps/test_set_user_password.py new file mode 100644 index 0000000..9f21b3a --- /dev/null +++ b/tests/installer-steps/test_set_user_password.py @@ -0,0 +1,56 @@ +"""Test that the primary user's password is set under a guard. + +archsetup runs with `set -e` OFF (line 21), so an unguarded `chpasswd` that +fails silently leaves the primary user with no password and no log entry -- an +unloggable account on a fresh install. set_user_password guards the chpasswd +with error_fatal so a failure aborts loudly instead of passing unnoticed. + +Method: sed-extract set_user_password from the real `archsetup`, run it with a +fake chpasswd (controlled exit) and a fake error_fatal recorder. Assert the +guard fires on failure and stays quiet on success. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_set_user_password +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run_set_password(chpasswd_rc): + """Run set_user_password with a fake chpasswd exiting chpasswd_rc.""" + script = textwrap.dedent(f"""\ + password="hunter2" + logfile=/dev/null + display() {{ :; }} + chpasswd() {{ cat >/dev/null; return {chpasswd_rc}; }} + error_fatal() {{ echo "FATAL: $1"; exit 9; }} + source <(sed -n '/^set_user_password() {{/,/^}}/p' "{ARCHSETUP}") + set_user_password "alice" + echo "REACHED-END" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class SetUserPassword(unittest.TestCase): + def test_success_does_not_abort(self): + r = run_set_password(0) + self.assertIn("REACHED-END", r.stdout) + self.assertNotIn("FATAL", r.stdout) + + def test_failure_aborts_with_error_fatal(self): + r = run_set_password(1) + self.assertIn("FATAL: setting password for alice", r.stdout, + "a chpasswd failure must abort via error_fatal") + self.assertNotIn("REACHED-END", r.stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/installer-steps/test_validate_yesno.py b/tests/installer-steps/test_validate_yesno.py new file mode 100644 index 0000000..da78fc5 --- /dev/null +++ b/tests/installer-steps/test_validate_yesno.py @@ -0,0 +1,57 @@ +"""Test the validate_yesno config-validation helper. + +The installer had four near-identical blocks validating that AUTOLOGIN, +NO_GPU_DRIVERS, INSTALL_CLAUDE_CODE, and INSTALL_DEVICE_UDEV_RULES are empty or +exactly yes/no. validate_yesno collapses them into one testable helper: empty +passes (the default), yes/no pass, anything else fails with a named error. + +Method: sed-extract validate_yesno from the real `archsetup` and drive it with +plain args. + +Run from repo root: + python3 -m unittest tests.installer-steps.test_validate_yesno +""" + +import os +import subprocess +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +ARCHSETUP = os.path.join(REPO_ROOT, "archsetup") + + +def run(name, value): + script = textwrap.dedent(f"""\ + source <(sed -n '/^validate_yesno() {{/,/^}}/p' "{ARCHSETUP}") + validate_yesno {name!r} {value!r} + echo "RC=$?" + """) + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=10, + ) + + +class ValidateYesno(unittest.TestCase): + def test_yes_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "yes").stdout) + + def test_no_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "no").stdout) + + def test_empty_passes(self): + self.assertIn("RC=0", run("AUTOLOGIN", "").stdout) + + def test_other_value_fails_with_named_error(self): + r = run("NO_GPU_DRIVERS", "maybe") + self.assertNotIn("RC=0", r.stdout) + self.assertIn("NO_GPU_DRIVERS", r.stderr) + self.assertIn("maybe", r.stderr) + + def test_capitalized_yes_fails(self): + # The values are compared exactly; "Yes" is not accepted. + self.assertNotIn("RC=0", run("AUTOLOGIN", "Yes").stdout) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/net-scenarios/test_run_net_scenarios.py b/tests/net-scenarios/test_run_net_scenarios.py new file mode 100644 index 0000000..1d92185 --- /dev/null +++ b/tests/net-scenarios/test_run_net_scenarios.py @@ -0,0 +1,102 @@ +"""Test run-net-scenarios.sh scenario accounting. + +Bug: the scenario_diagnose_expect else-branch printed a FAIL line but never +forced a non-zero subshell exit, so a scenario whose only failure was the +diagnose check counted as passed and the run printed "all scenarios passed" +with exit 0. A net-doctor diagnosis regression would ship behind a green run. + +Method: run the real script against a temp scenario dir (NET_SCENARIO_DIR) +with stub ssh/rsync/jq on PATH, driving one fake scenario whose check results +are controlled per test. Assert on the script's exit code and summary line. + +Run from repo root: + python3 -m unittest tests.net-scenarios.test_run_net_scenarios +""" + +import os +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts", "testing", "run-net-scenarios.sh") + +STUB = "#!/bin/sh\ncat >/dev/null 2>&1 || true\nexit 0\n" + + +class RunNetScenarios(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="net-scen-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.bindir = os.path.join(self.tmp, "bin") + os.mkdir(self.bindir) + for tool in ("ssh", "rsync", "jq"): + p = os.path.join(self.bindir, tool) + with open(p, "w") as f: + f.write(STUB) + os.chmod(p, 0o755) + self.scen_dir = os.path.join(self.tmp, "scenarios") + os.mkdir(self.scen_dir) + + def write_scenario(self, *, brk=0, diagnose=0, fix=0, assert_rc=0, + with_diagnose=True): + diag_fn = ( + f"scenario_diagnose_expect() {{ return {diagnose}; }}\n" + if with_diagnose else "" + ) + body = textwrap.dedent(f"""\ + SCENARIO_DESC="fake scenario for harness tests" + scenario_break() {{ return {brk}; }} + {diag_fn}scenario_fix() {{ return {fix}; }} + scenario_assert() {{ return {assert_rc}; }} + """) + with open(os.path.join(self.scen_dir, "fake-scenario.sh"), "w") as f: + f.write(body) + + def run_script(self): + env = dict(os.environ) + env["PATH"] = self.bindir + os.pathsep + env["PATH"] + env["NET_SCENARIO_DIR"] = self.scen_dir + env["DOTFILES"] = self.tmp # rsync is stubbed; path just has to exist + return subprocess.run( + ["bash", SCRIPT, "--target", "root@fake-vm"], + capture_output=True, text=True, timeout=20, env=env, + ) + + def test_all_checks_pass_exits_zero(self): + self.write_scenario() + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertIn("all scenarios passed", r.stdout) + + def test_diagnose_failure_alone_fails_the_run(self): + # The bug: break/fix/assert all pass, only the diagnose check fails. + self.write_scenario(diagnose=1) + r = self.run_script() + self.assertIn("diagnose did NOT name it", r.stdout) + self.assertNotEqual(r.returncode, 0, + "a diagnose regression must not exit green") + self.assertIn("1 scenario(s) failed", r.stdout) + + def test_assert_failure_fails_the_run(self): + self.write_scenario(assert_rc=1) + r = self.run_script() + self.assertNotEqual(r.returncode, 0) + self.assertIn("NOT repaired", r.stdout) + + def test_break_failure_fails_the_run(self): + self.write_scenario(brk=1) + r = self.run_script() + self.assertNotEqual(r.returncode, 0) + self.assertIn("break failed", r.stdout) + + def test_scenario_without_diagnose_hook_still_passes(self): + self.write_scenario(with_diagnose=False) + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stdout + r.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/normalize-notify/fake-ffmpeg b/tests/normalize-notify/fake-ffmpeg new file mode 100644 index 0000000..cdfcb6f --- /dev/null +++ b/tests/normalize-notify/fake-ffmpeg @@ -0,0 +1,25 @@ +#!/bin/bash +# Fake ffmpeg for the normalize-notify-sounds tests. Two modes: +# measure (args include the `volumedetect` filter): print a mean_volume line +# to stderr, driven by FAKE_MEAN (default -20.0). +# encode (otherwise): write to the output file (the last argument), driven +# by FAKE_FFMPEG_EMPTY (1 -> zero-byte output) and FAKE_FFMPEG_FAIL +# (1 -> exit non-zero without writing). +set -uo pipefail + +for a in "$@"; do + if [ "$a" = "volumedetect" ]; then + echo "mean_volume: ${FAKE_MEAN:--20.0} dB" >&2 + exit 0 + fi +done + +out="${*: -1}" +if [ "${FAKE_FFMPEG_FAIL:-0}" = 1 ]; then + exit 1 +fi +if [ "${FAKE_FFMPEG_EMPTY:-0}" = 1 ]; then + : > "$out" +else + echo ENCODED > "$out" +fi diff --git a/tests/normalize-notify/test_normalize_notify_sounds.py b/tests/normalize-notify/test_normalize_notify_sounds.py new file mode 100644 index 0000000..ce3084b --- /dev/null +++ b/tests/normalize-notify/test_normalize_notify_sounds.py @@ -0,0 +1,117 @@ +"""Tests for normalize-notify-sounds.sh temp handling and atomic write. + +The script re-encodes each .ogg in place. SOUND_DIR is often the stow-symlinked +~/.local copy, so the write must land on the real repo file and preserve the +symlink. Two bugs the fix addresses: + +- A failed or empty re-encode used to truncate the target with `cat "$tmp" > + "$f"`, corrupting a repo-tracked sound to zero bytes. +- The mktemp had no EXIT trap, so an interrupted encode leaked a temp file. + +ffmpeg/ffprobe are faked via stubs on PATH (this directory) so the encode +result is controllable without real audio work. + +Run from repo root: + python3 -m unittest tests.normalize-notify.test_normalize_notify_sounds +""" + +import glob +import os +import shutil +import subprocess +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +SCRIPT = os.path.join(REPO_ROOT, "scripts", "normalize-notify-sounds.sh") + + +class NormalizeNotifySounds(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix="norm-notify-test-") + self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True) + self.bindir = os.path.join(self.tmp, "bin") + os.mkdir(self.bindir) + for tool in ("ffmpeg", "ffprobe"): + fake = os.path.join(HERE, "fake-" + tool) + if not os.path.exists(fake): + fake = os.path.join(HERE, "fake-ffmpeg") # ffprobe reuses stub + dst = os.path.join(self.bindir, tool) + shutil.copy(fake, dst) + os.chmod(dst, 0o755) + # sound dir with one .ogg, plus a "repo" the sound symlinks into + self.repo = os.path.join(self.tmp, "repo") + os.mkdir(self.repo) + self.sounddir = os.path.join(self.tmp, "sounds") + os.mkdir(self.sounddir) + + def run_script(self, env_extra=None): + env = dict(os.environ) + env["PATH"] = self.bindir + os.pathsep + env["PATH"] + if env_extra: + env.update(env_extra) + return subprocess.run( + ["bash", SCRIPT, self.sounddir], + capture_output=True, text=True, timeout=20, env=env, + ) + + def make_sound(self, name="chime.ogg", body="ORIGINAL\n", symlink=False): + if symlink: + real = os.path.join(self.repo, name) + with open(real, "w") as f: + f.write(body) + link = os.path.join(self.sounddir, name) + os.symlink(real, link) + return link, real + path = os.path.join(self.sounddir, name) + with open(path, "w") as f: + f.write(body) + return path, path + + def leftover_temps(self, directory): + return glob.glob(os.path.join(directory, "*.ogg.*")) + \ + [p for p in glob.glob(os.path.join(directory, "tmp*")) if p.endswith(".ogg")] + + # --- Normal ---------------------------------------------------------- + + def test_reencodes_regular_file(self): + path, _ = self.make_sound() + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + with open(path) as f: + self.assertEqual(f.read(), "ENCODED\n") + + def test_preserves_symlink_and_updates_target(self): + link, real = self.make_sound(symlink=True) + r = self.run_script() + self.assertEqual(r.returncode, 0, r.stderr) + self.assertTrue(os.path.islink(link), "the stow symlink must survive") + with open(real) as f: + self.assertEqual(f.read(), "ENCODED\n", + "the re-encode must land on the real repo file") + + # --- Error / corruption guard --------------------------------------- + + def test_empty_reencode_does_not_corrupt_target(self): + path, _ = self.make_sound(body="ORIGINAL\n") + self.run_script(env_extra={"FAKE_FFMPEG_EMPTY": "1"}) + with open(path) as f: + self.assertEqual(f.read(), "ORIGINAL\n", + "an empty re-encode must not truncate the tracked file") + + def test_failed_encode_leaves_no_temp_in_target_dir(self): + self.make_sound(symlink=True) + self.run_script(env_extra={"FAKE_FFMPEG_FAIL": "1"}) + self.assertEqual(self.leftover_temps(self.repo), [], + "a failed encode must not leak a temp file") + + def test_failed_encode_does_not_corrupt_target(self): + _, real = self.make_sound(symlink=True, body="ORIGINAL\n") + self.run_script(env_extra={"FAKE_FFMPEG_FAIL": "1"}) + with open(real) as f: + self.assertEqual(f.read(), "ORIGINAL\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/nvidia-preflight/test_nvidia_preflight.py b/tests/nvidia-preflight/test_nvidia_preflight.py index bdacfd5..191e983 100644 --- a/tests/nvidia-preflight/test_nvidia_preflight.py +++ b/tests/nvidia-preflight/test_nvidia_preflight.py @@ -50,7 +50,8 @@ class NvidiaPreflightHarness(unittest.TestCase): "#!/bin/bash\n" 'ARCHSETUP="$1"; shift\n' "source <(sed -n " - "'/^nvidia_preflight_report() {/,/^}/p' \"$ARCHSETUP\")\n" + "'/^NVIDIA_MIN_DRIVER=/p;" + "/^nvidia_preflight_report() {/,/^}/p' \"$ARCHSETUP\")\n" "nvidia_preflight_report\n" ) os.chmod(self.wrapper, 0o755) diff --git a/tests/vm-framework/test_vm_utils.py b/tests/vm-framework/test_vm_utils.py new file mode 100644 index 0000000..579955b --- /dev/null +++ b/tests/vm-framework/test_vm_utils.py @@ -0,0 +1,130 @@ +"""Tests for the VM test framework's vm-utils helpers. + +Two robustness bugs under test: + +1. init_vm_paths suffixed DISK_PATH and OVMF_VARS by FS_PROFILE but left + PID_FILE, MONITOR_SOCK, and SERIAL_LOG unsuffixed, so a concurrent btrfs + and zfs run shared them -- the second run's stop/is-running logic read the + first run's PID and could kill the other VM. All runtime paths now carry + the profile suffix. +2. kill_qemu sent kill -9 and deleted the PID file without waiting for the + process to die, so a force-kill fallback could run qemu-img snapshot + against a qcow2 the dying qemu still held locked. kill_qemu now waits for + the process to be dead (or reaped) before returning. + +Method: sed-extract init_vm_paths / kill_qemu / _cleanup_qemu_files from +lib/vm-utils.sh and drive them with temp dirs and real background processes. + +Run from repo root: + python3 -m unittest tests.vm-framework.test_vm_utils +""" + +import os +import subprocess +import tempfile +import textwrap +import unittest + +REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +VM_UTILS = os.path.join(REPO_ROOT, "scripts", "testing", "lib", "vm-utils.sh") + +EXTRACT = ( + "source <(sed -n '/^init_vm_paths() {{/,/^}}/p;" + "/^kill_qemu() {{/,/^}}/p;" + "/^_cleanup_qemu_files() {{/,/^}}/p' \"{lib}\")" +).format(lib=VM_UTILS) + + +def run(body): + script = f"fatal() {{ echo \"FATAL: $*\"; exit 1; }}\nwarn() {{ :; }}\n{EXTRACT}\n{body}\n" + return subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, timeout=30, + ) + + +class InitVmPaths(unittest.TestCase): + def paths_for(self, profile): + with tempfile.TemporaryDirectory() as d: + body = textwrap.dedent(f"""\ + FS_PROFILE={profile} + init_vm_paths "{d}" + echo "DISK=$DISK_PATH" + echo "VARS=$OVMF_VARS" + echo "PID=$PID_FILE" + echo "MON=$MONITOR_SOCK" + echo "SER=$SERIAL_LOG" + """) + return run(body).stdout + + def test_zfs_profile_suffixes_all_runtime_paths(self): + out = self.paths_for("zfs") + self.assertIn("archsetup-base-zfs.qcow2", out) + self.assertIn("OVMF_VARS-zfs.fd", out) + # The bug: these three shared one name across profiles. + self.assertIn("PID=", out) + self.assertRegex(out, r"PID=.*-zfs", + "PID_FILE must carry the profile suffix") + self.assertRegex(out, r"MON=.*-zfs", + "MONITOR_SOCK must carry the profile suffix") + self.assertRegex(out, r"SER=.*-zfs", + "SERIAL_LOG must carry the profile suffix") + + def test_btrfs_profile_keeps_legacy_unsuffixed_names(self): + out = self.paths_for("btrfs") + self.assertIn("DISK=", out) + self.assertIn("archsetup-base.qcow2", out) + self.assertNotIn("-btrfs", out) + + def test_invalid_profile_fatals(self): + out = self.paths_for("ext4") + self.assertIn("FATAL", out) + + +class KillQemu(unittest.TestCase): + def test_process_is_dead_before_return(self): + # Contract pin for the snapshot-restore race: when kill_qemu returns, + # the process must be dead or reaped -- state Z or /proc entry gone -- + # so a following qemu-img call can't hit a still-held qcow2 lock. + with tempfile.TemporaryDirectory() as d: + body = textwrap.dedent(f"""\ + PID_FILE="{d}/qemu.pid" + MONITOR_SOCK="{d}/qemu-monitor.sock" + sleep 60 & victim=$! + echo "$victim" > "$PID_FILE" + kill_qemu + state=$(awk '{{print $3}}' "/proc/$victim/stat" 2>/dev/null || echo GONE) + echo "STATE=$state" + [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed" + """) + r = run(body) + self.assertRegex(r.stdout, r"STATE=(Z|GONE)", + f"process still live after kill_qemu: {r.stdout}") + self.assertIn("PIDFILE=removed", r.stdout) + + def test_stale_pid_file_is_cleaned_quietly(self): + with tempfile.TemporaryDirectory() as d: + body = textwrap.dedent(f"""\ + PID_FILE="{d}/qemu.pid" + MONITOR_SOCK="{d}/qemu-monitor.sock" + echo 99999999 > "$PID_FILE" + kill_qemu + echo "RC=$?" + [ -f "$PID_FILE" ] && echo "PIDFILE=present" || echo "PIDFILE=removed" + """) + r = run(body) + self.assertIn("RC=0", r.stdout) + self.assertIn("PIDFILE=removed", r.stdout) + + def test_no_pid_file_is_a_noop(self): + with tempfile.TemporaryDirectory() as d: + body = textwrap.dedent(f"""\ + PID_FILE="{d}/qemu.pid" + MONITOR_SOCK="{d}/qemu-monitor.sock" + kill_qemu + echo "RC=$?" + """) + self.assertIn("RC=0", run(body).stdout) + + +if __name__ == "__main__": + unittest.main() @@ -45,52 +45,12 @@ below): input-side-spec.org (DRAFT, four decisions open). * Archsetup Open Work -** TODO [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo: -Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B]. -archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3). -** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network: -CLOSED: [2026-07-20 Mon] -Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B]. -Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys. -Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only). -Follow-ups filed below: harden cgit exposure; installer no longer ships configs. ** TODO [#B] Audit cgit-published repos for secrets and privacy :bug:security: Grading: security carve-out — cgit at git.cjennings.net serves every repo under scan-path=/var/git over unauthenticated https (any repo is anonymously cloneable). archsetup being public is by design (curl-install), but this means ANY secret in ANY /var/git repo is world-readable, and any repo meant to be private is not. Severity depends on what else lives there = P2 = [#B], raise if a private repo with secrets is found. -Not :solo: — needs Craig's decisions. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (keys, tokens, credentials) the way this WireGuard leak was found; for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. Check dotfiles (git.cjennings.net/dotfiles) specifically — it is also under /var/git. -** TODO [#C] WireGuard import is now config-less — decide feature fate :feature:network: -scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). Decide: remove the import feature entirely, or keep it and document dropping plaintext configs into the dir out-of-band at install time (they stay gitignored). If kept, confirm the script no-ops gracefully when the dir has no *.conf. -** TODO [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick: -Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C]. -archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2). -** TODO [#C] Installer nvme early module never built into initramfs :bug:solo: -Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C]. -archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3). -** TODO [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick: -Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C]. -archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1). -** TODO [#C] Installer run_step state + exit-code handling :bug:solo: -Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C]. -archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1). -** TODO [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail: -Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C]. -scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4). -** TODO [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick: -Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C]. -archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2). -** TODO [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network: -Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C]. -scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4). -** TODO [#D] Installer resume-idempotency cluster :bug:solo: -Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots. -crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3). -** TODO [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick: -Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D]. -With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3). -** TODO [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick: -Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D]. -scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4). -** TODO [#D] Installer + scripts refactor opportunities :refactor: -Grading: no behavior change; parking lot. 12 refactors from the sentry audit — duplicated GPU-modalias scan, triple hand-rolled retry loop, stow x4, display_server/window_manager dispatch dup, Maia ELO range x3, per-script log helpers, GRUB/snapper/fsck sed clusters, waybar-battery positional sed. Full list with line numbers in [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (High/Medium/Low tagged). Pull individual ones out as their own tasks when tackled. +Not :solo: — needs Craig's decisions. Steps: list repos under /var/git; for each, decide intended public vs private; scan each for secrets (keys, tokens, credentials) the way this WireGuard leak was found; for any meant-to-be-private repo, actually restrict access (cgit repo.hide only hides from the index — a known repo name is still cloneable; use http auth or move it off the public scan-path); for public repos, confirm no secrets and add a pre-receive/CI secret scan. Check dotfiles (git.cjennings.net/dotfiles) specifically — it is also under /var/git. archsetup's own move is decided and tracked separately below. +** TODO [#B] Move archsetup off cgit to cjennings@cjennings.net :chore:security: +Decided (Craig, 2026-07-20): move the archsetup repo off the public cgit host (git@cjennings.net, scan-path /var/git) to Craig's private account remote cjennings@cjennings.net, so it is no longer world-cloneable. This is the archsetup-specific fix for the cgit-exposure finding above. +Plan: create a bare repo under cjennings's control off the cgit scan-path (e.g. =~cjennings/git/archsetup.git=); push current main + tags there; migrate the post-receive hook that publishes the installer to =/var/www/cjennings/archsetup= so curl-install keeps working (the single published file stays public by design; only the repo goes private); update the origin remote on ratio and velox to =cjennings@cjennings.net:git/archsetup.git=; remove =/var/git/archsetup.git= so cgit no longer serves it. Verify: anonymous =git clone https://git.cjennings.net/archsetup.git= fails, the new private clone works from both machines, and the curl-install URL still returns the installer. Keep the two daily drivers' remotes in sync (daily-drivers rule). ** TODO [#B] Velox boot-failure retrospective — upgrade guard gaps :bug:zfs:maint: Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/maint code review: - maint's UPDATE remedy runs a plain =yay -Syu --noconfirm= (remedies.py:297). The live-update guard (guard.py) only matches mesa/hyprland (the 2026-06-07 live-swap class) — it never checks /boot, kernel, initramfs, or mkinitcpio exit. No post-upgrade /boot assertion exists. An interrupted kernel transaction slips straight through. @@ -98,31 +58,17 @@ Post-mortem for the 2026-07-15 velox no-kernel boot failure, from the archsetup/ - Sanoid-vs-actual dataset drift: configure_zfs_snapshots configures zroot/var/log + zroot/var/lib/pacman as separate datasets; velox's actual layout has neither separate (/var/log sits inside zroot/var). Reconcile. - Confirm the pre-pacman snapshot hook is actually installed + firing on velox (it should be — it's what makes recovery possible). -** TODO [#C] Add inetutils to install base :feature:solo:quick:network: -TRAMP's /ftp: method (ange-ftp) shells out to a command-line ftp client; Arch ships none by default. GNU inetutils provides =/usr/bin/ftp=. Craig's dirvish config has an FTP quick-access entry (phone FTP server), so it's a config dependency. Installed manually on ratio 2026-07-14; velox needs it once it boots. Add to the install base so future machines get it for free; verify via VM test. From .emacs.d handoff 2026-07-14-1751. - -** TODO [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: -Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit. -The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run. - +** TODO [#B] Hyprland layoutmsg crash — bad_variant_access (upstream) :bug:hyprland: +Grading: Critical severity (SIGSEGV kills the whole desktop session; every GUI app's unsaved state lost) x rare edge case (twice in ~4.5 months: 2026-03-07 on v0.54.1, 2026-07-20 on v0.55.4) = P2 = [#B]. Upstream Hyprland bug, not this repo's code — the task tracks reporting it and picking up the fix. +A layoutmsg mfact dispatch (layout-resize, mod+H/L) throws std::bad_variant_access inside Layout::CAlgorithm::layoutMsg, uncaught, SIGSEGV. Both crashes fired from the layout-resize mfact path (keycode 104 shrink today, 108 grow in March). Layout at crash was master and the identical mfact had worked seconds earlier; the pre-crash window held monocle<->master toggles, two window closes dropping focus to "[Window nullptr]", and togglefloating x2. Monocle is a registered v0.55 layout (log shows graceful "Unknown monocle layoutmsg" rejects), so the config is not at fault; related edges are guarded ("mfact -> no window") while this path misses its variant guard. Repo has no newer build (0.55.4-1 installed and repo). +Evidence preserved in [[file:working/hyprland-layoutmsg-crash/][working/hyprland-layoutmsg-crash/]] (both crash reports + excerpts from the tmpfs session log, extracted before reboot loses it). +Next: Craig posts the issue himself (2026-07-20 decision) — the voice-passed draft is [[file:working/hyprland-layoutmsg-crash/issue-draft.md][issue-draft.md]], with both crash reports and the log excerpts beside it for attaching. Watch the repo for a fixed release and close on confirmation. The layout-resize script guard was declined (a script can't observe the internal desync). ** TODO [#B] Assess a Hyprland left-drag window gesture :feature:hyprland: Evaluate whether a global left-click drag can move ordinary windows without breaking application selection, text interaction, or Wayland security expectations. Document the safe modifier/gesture alternatives before changing any binding. -** TODO [#C] Add a time selector to the timer panel :feature:timer: -Offer a period-appropriate selector for timer duration, likely drawing on the -tape-counter idiom, while preserving the existing direct-entry path. - -** TODO [#C] Order network-panel connections by availability :feature:network: -Present saved and currently available networks in this order: available saved -profiles, available unsaved networks, then saved profiles that are unavailable. - -** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar: -Detect hotspot/metered connectivity and render the WiFi icon plus SSID amber, -while ordinary WiFi stays white. - ** TODO [#B] Reconcile panel keybindings around Super+N :feature:hyprland: Swap the notification and networking bindings so primary panels are one Super-plus-letter chord away, audit the other exceptions, and bring the @@ -137,10 +83,6 @@ maintenance console. Expose channel-level input and output volume controls without losing the existing device-level workflow. -** TODO [#C] Add a whole-display dim mode :feature:hyprland: -Extend auto-dim with an explicit “dim everything” setting for bright -non-dark-mode contexts, with a security/usability review of its scope. - ** DOING [#B] Widget gallery upgrades :feature:design: :PROPERTIES: :LAST_REVIEWED: 2026-07-13 @@ -371,27 +313,6 @@ Restyle the audio panel's GTK CSS onto =tokens-waybar.css= + the banked composit After ~5 hand ports, weigh widget-level codegen with evidence (mechanical duplication vs judgment per port). Recorded as a dated decision in the spec; go spawns its own spec. *** TODO Flip the spec to IMPLEMENTED When the phases above close: status heading keyword → =IMPLEMENTED=, dated history line with the reason, Metadata =Status= mirror. Three lines, one file. -** TODO [#C] Gallery probe: the fader-drag check is flaky :bug:test:design: -=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch. - -Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=. - -Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured. - -Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape. - -*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug). - -Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. - -Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns). Both were the session's first probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). - -** TODO [#C] Maint live-refresh hairline replacement :feature:maint: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. - ** TODO [#B] Net doctor expansion v1 — VM live verification :feature:dotfiles:network: :PROPERTIES: :SPEC_ID: ce29b103-ed9d-4f56-bf8c-9ed8fe680ff3 @@ -413,37 +334,6 @@ On dotfiles main (=12e3e76=, pushed). =gather_context= derives an auth cause on *** 2026-07-12 Sun @ 09:14:00 -0500 Flipped the net spec to IMPLEMENTED and logged the vNext items Spec status heading now IMPLEMENTED (dated history line + Status mirror); all four phase headings DONE. vNext items (flaky/drops cluster, DoT/DNSSEC verdict, profile hygiene) logged as the "Net doctor vNext" task. The privileged-fix live halves remain with the VM live-verification sub-task and the manual-testing checklist — findings there come back as bugs. -** TODO [#C] Net panel speedtest history :feature:dotfiles:network: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues. - -** TODO [#D] Net doctor vNext :feature:dotfiles:network: -Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion spec]] (IMPLEMENTED, v1 shipped): event-log correlation for the flaky/drops cluster (powersave, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloop drop signatures — needs history a one-shot probe can't see); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. - -** TODO [#D] Bt doctor vNext :feature:dotfiles:bluetooth: -Deferred from the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion spec]] (IMPLEMENTED, v1 shipped): the stale-bond re-pair-offer signature (v1 keeps re-pair strictly user-initiated; the signature must be designed and validated before the doctor ever offers it); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict; the bt-audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume) — wants coordination with the audio doctor so the two panels don't claim the same A2DP/HFP diagnosis with divergent verdicts. - -** TODO [#D] Widget catalogue vNext :feature:design: -Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear. - -** TODO [#D] Maintenance console vNext :feature: -Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level. - -** TODO [#D] Retention-repair remedy unreachable from the panel GUI :bug:dotfiles: -snapshot_retention_repair (WRITE SANE LIMITS — =snapper -c <config> set-config TIMELINE_CREATE=yes TIMELINE_LIMIT_*=<TOML values>=) sits in the maint remedy table but is wired into no GUI layer (no reference in panel.py, viewmodel.py, or gui.py) — it's reachable only via =maint doctor review= / =maint fix=. Worse, the REVIEW & FIX roster's wording for item-bearing remedies says "per-item — on its subpanel", which for this remedy points at a key that doesn't exist. Found 2026-07-08 while walking the SNAPSHOTS subpanel with Craig. - -Two fix shapes, decide at work time: (1) wire a digest key — e.g. on the timeline row when the config's installed TIMELINE_LIMIT_* drift from the TOML values (needs the probe to read the installed limits for comparison); or (2) keep it CLI-only deliberately and special-case the roster wording for remedies with no panel key ("CLI only — maint fix"). Option 2 is a few lines; option 1 makes limit drift visible on the board, which is the 2026-05-26 pile-up's root cause. Severity minor × rare edge = P4 per the bug matrix. - -** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-09 -:END: -=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness. -*** 2026-07-14 Tue @ 01:40:48 -0500 Diagnosed: OpenZFS/kernel version skew, blocked on archzfs -Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= exits 1 during pacstrap. Root cause confirmed: OpenZFS 2.3.3's META declares Linux-Maximum 6.15, and the VM installs linux-lts 6.18.38. The first release supporting 6.18 is 2.4.0 (2.4.1 covers 6.19), and archzfs currently serves only zfs-dkms 2.3.3-1 — nothing on our side to fix. Unblock condition: archzfs publishes zfs-dkms ≥2.4.0; recheck with =curl -s https://archzfs.com/archzfs/x86_64/ | grep -o 'zfs-dkms-[0-9.]*'=, then rerun =FS_PROFILE=zfs make test-vm-base=. - ** TODO [#B] Consistent keybinding family for the panel console :feature:hyprland: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -481,20 +371,6 @@ Disabled 2026-06-12 (bind and cycle entry points removed; Super+Shift+S reassign The support machinery was deliberately kept for this task: =layout-navigate= and =layout-resize= retain their scrolling branches, =waybar-layout= still renders the scrolling state, and the unbound legacy =cycle-layout= script still lists it. Re-enabling is two lines: add =scrolling= back to =LAYOUTS= in =layout-cycle= and restore a direct-jump bind (the old chord is taken now — pick a new one). The =tests/layout-cycle= suite pins the disabled state and will go red on re-enable, which is the reminder to update it. -** TODO [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion) -Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo. -*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) -The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). -From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). - -Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a decision and a paused-machine check): -- Waypaper canonical decision (Craig): live velox has =dark-lion.jpg=, repo has =that-one-up-there.jpg= (placeholder?). Decide the canonical, then =make reset= clears it. -- Ratio check: whether =~/.config/calibre/*.json= on ratio are symlinks or real files — same first-launch gap likely if calibre ever launched there before stow. On the ratio trip list. - ** TODO [#B] Audit dotfiles/common directory :chore:dotfiles: :PROPERTIES: :LAST_REVIEWED: 2026-07-14 @@ -622,18 +498,6 @@ Build handed off to the dotfiles project 2026-07-04 (=~/.dotfiles/inbox/2026-07- *** TODO Phase 5 — VPN / WireGuard CLI fold (vNext) :network: Rescoped 2026-07-04 (audit): the tunnels track already shipped most of the original Phase 5. Panel tunnel bring-up/down and detection landed (dotfiles 2d9d060 probes tailscale/NM-wireguard/Proton; 21db05a brings overlays up/down from the panel's Tunnels sub-view; 31ba056 diagnose/doctor understand tunnel routes; archsetup 2e40781 wireguard config import; the net-panel-other-interfaces spec is IMPLEMENTED). What remains for Phase 5 is only the =net vpn ...= CLI subcommand — cli.py still has no vpn/tunnel parser. Fold the panel's existing tunnel operations into a CLI surface; spec separately when picked up. -** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call. - -** TODO [#C] Net panel: driver-health diagnostic tier :feature:network: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-14 -:END: -Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer. - ** TODO [#B] Desktop-settings dropdown panel :feature:waybar: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -701,14 +565,6 @@ Boot the configured endpoint and send a short prompt; surface success/failure + Acceptance: fresh VM install of the ratio profile reaches an endpoint on =:8081= that answers a smoke prompt; velox profile gets Q4_K_M + 8B and answers a prompt within reasonable laptop latency; network-down install completes successfully with the pending-models warning surfaced. -** TODO [#C] Voice dictation / speech-to-text input :feature:tooling: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03. - -Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter, lower accuracy) vs a =whisper.cpp=-based daemon (heavier, higher accuracy, optional GPU). Wayland typing backend is =wtype= or =ydotool=. Scope once chosen: install + model download, a push-to-talk keybind (Hyprland), and an autostart entry; fold into archsetup so it lands on both daily drivers. Consider an Emacs-native path (=whisper.el=) as a complement for in-buffer dictation. - ** TODO [#B] Review post-archsetup laptop setup steps (velox 2026-04-10) :PROPERTIES: :LAST_REVIEWED: 2026-07-04 @@ -851,34 +707,6 @@ Need to investigate proper machine-ID regeneration that doesn't break networking Would enable parallel test execution in CI/CD Priority C because snapshot-based testing meets current needs -** TODO [#C] Fix install errors surfaced by the 2026-05-11 VM test run -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set -Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread. - -*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set -The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized. -Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6. -- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=). -- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install). -- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above). -- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug. -- verifying firewall is active — exit 1 (follow-on from the firewall-enable error). -- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical. -- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?). -Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM). - -*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root -Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task. - -*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern -2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow. - -*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end -=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path. - ** TODO [#B] Review undeclared ratio packages for installer inclusion :chore: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -986,72 +814,6 @@ Add kernel parameter: ~rtc_cmos.use_acpi_alarm=1~ (will become systemd default) Consider: ~acpi_mask_gpe=0x1A~ for battery drain, suspend-then-hibernate config See Framework community notes on logind.conf and sleep.conf settings -** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo: -:PROPERTIES: -:LAST_REVIEWED: 2026-07-09 -:END: -archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change. - -*** 2026-07-14 Tue @ 01:40:48 -0500 Rechecked: still needed, same cause -Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1 (PKGBUILD unchanged): tarball passes, =LICENSE.txt= still FAILS its b2sum — the github-master pin, structural as diagnosed 2026-06-24. =--skipinteg= stays. - -*** 2026-07-02 Thu @ 05:08:37 -0400 Rechecked: still needed, same cause -=makepkg --verifysource= on 3.7.0-1: tarball passes, =LICENSE.txt= still FAILS -its b2sum — the PKGBUILD still pins a hash for a file fetched from github -master. Structural, as diagnosed 2026-06-24; =--skipinteg= stays. - -*** 2026-06-24 Wed @ 17:55:34 -0400 Rechecked: still needed, but the cause changed -Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package tarball =lyricsgenius-3.7.0.tar.gz= now passes its b2sum — the original expired-PGP-signature problem is gone (the PKGBUILD no longer carries any =validpgpkeys=). But integrity still FAILS, on a different file: =LICENSE.txt=, which the PKGBUILD fetches from the project's github master and pins a b2sum for. github master is a moving target, so that b2sum drifts and =--skipinteg= is still required. This is structural (not a transient upstream fix away), so it likely won't clear until the maintainer pins the LICENSE to a tagged release. Updated the archsetup comment to the real cause. Keep rechecking, but lower expectations of it clearing. - -** TODO [#C] Review theme config architecture for dunst/fuzzel -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -The active dunst config is stowed from dotfiles/common/ but theme templates -live in dotfiles/hyprland/.config/themes/. set-theme copies the templates to -the stowed locations at runtime, so edits to the common file get overwritten -on theme switch. This split between stowed configs and theme templates is -error-prone — changes must be made in both places. Consider: -- Having set-theme be the single source of truth (remove common dunstrc from stow) -- Or symlinking the stowed config to a theme-managed location -- Same situation applies to fuzzel.ini -The goal is a single place to edit each config, not two. - -** TODO [#C] Review current tool pain points annually -:PROPERTIES: -:LAST_REVIEWED: 2026-07-06 -:END: -Once-yearly systematic inventory of known deficiencies and friction points in current toolset - -** TODO [#D] Consider Customizing Hyprland Animations -Current: windows pop in, scratchpads slide from bottom. - -Customizable animations: -- windows / windowsOut / windowsMove - window open/close/move -- fade - opacity changes -- border / borderangle - border color and gradient angle -- workspaces - workspace switching -- specialWorkspace - scratchpads (currently slidevert) -- layers - waybar, notifications, etc. - -Styles: slide, slidevert, popin X%, fade -Parameters: animation = NAME, ON/OFF, SPEED, BEZIER, STYLE -Speed: lower = faster (1-10 typical) - -Example tweaks: -#+begin_src conf -animation = windows, 1, 2, myBezier, popin 80% -animation = workspaces, 1, 4, default, slide -animation = fade, 1, 2, default -animation = layers, 1, 2, default, fade -#+end_src - -** TODO [#D] Parse and improve AUR error reporting -Parse yay errors and provide specific, actionable fixes instead of generic error messages - -** TODO [#D] Improve progress indicators throughout install -Enhance existing indicators to show what's happening in real-time - ** TODO [#B] Manual testing and validation :test: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -1693,6 +1455,221 @@ Specced 2026-07-10 after discussion with Craig, and the design grew past the ori Parent spec: [[file:docs/specs/2026-07-09-audio-doctor-spec.org][docs/specs/2026-07-09-audio-doctor-spec.org]] (IMPLEMENTED). This is a v1 gap found after the fact, not a phase of it. +** TODO [#C] WireGuard import is now config-less — decide feature fate :feature:network: +scripts/import-wireguard-configs.sh reads assets/wireguard-config/*.conf, but no configs ship in the repo anymore (removed as a public-leak fix; .gitignore blocks plaintext). Decide: remove the import feature entirely, or keep it and document dropping plaintext configs into the dir out-of-band at install time (they stay gitignored). If kept, confirm the script no-ops gracefully when the dir has no *.conf. +** TODO [#C] Waybar modules run together — need subtle separators :bug:dotfiles:waybar: +Craig misreads where one module ends and the next begins — the wind (weather) value runs straight into the date with no visual stop, so he reads the wind figure as the start of the date. Add a light, subtle separator or spacing between adjacent Waybar modules. +Grading: Minor severity (legibility, nothing broken) x frequent (every glance at the bar) = P3 = [#C]. +Not fully :solo: — needs Craig's eye on the result (separator style is a taste call, plus a live visual check). Prior work added a date-facing divider (dotfiles 103cccb); evidently not enough, so revisit the whole inter-module treatment rather than just the weather/date seam. From .emacs.d handoff 2026-07-20-1114 (roam capture; waybar is archsetup-owned per the dotfiles standing rule). +** TODO [#C] Dupre theme waybar.css drifted from live style.css :bug:dotfiles:waybar: +Grading: Minor severity (cosmetic, reverts only on a theme switch) × rare edge case (dupre is already the active theme) = P4 = [#D] on user impact, bumped to [#C] because the dotfiles =make test= stays RED until synced, poisoning the green baseline for every future commit. +The weather-kit work added =#custom-weather= selectors to =hyprland/.config/waybar/style.css= but never mirrored them into =hyprland/.config/themes/dupre/waybar.css=. =tests/theme-css= asserts the two files are identical (set-theme copies the theme file over the live one), so switching to dupre would silently revert the weather chip styling. Fix: sync the theme file to live. Pre-existing; found 2026-07-19 during an unrelated commit's green-baseline run. + +** TODO [#C] Add a time selector to the timer panel :feature:timer: +Offer a period-appropriate selector for timer duration, likely drawing on the +tape-counter idiom, while preserving the existing direct-entry path. + +** TODO [#C] Order network-panel connections by availability :feature:network: +Present saved and currently available networks in this order: available saved +profiles, available unsaved networks, then saved profiles that are unavailable. + +** TODO [#C] Indicate hotspot or metered WiFi in amber :feature:network:waybar: +Detect hotspot/metered connectivity and render the WiFi icon plus SSID amber, +while ordinary WiFi stays white. + +** TODO [#C] Add a whole-display dim mode :feature:hyprland: +Extend auto-dim with an explicit “dim everything” setting for bright +non-dark-mode contexts, with a security/usability review of its scope. + +** TODO [#C] Gallery probe: the fader-drag check is flaky :bug:test:design: +=probe.mjs= check 3 ("fader drag tracks at 3x") intermittently reports =level 68 -> level 68=, i.e. the synthetic drag never registers. It has presumably been doing this all along unnoticed, since the suite is normally run once per batch. + +Grading: *Minor* severity (a false FAIL costs a re-run and a few minutes, and never ships a defect) x *most users, frequently* = P3 = =[#C]=. + +Frequency measured 2026-07-16, not estimated: 1 failure in 6 consecutive runs, having already fired twice in about fifteen that afternoon. The first grading guessed "some users, sometimes" (~1 in 10); at ~1 in 6, both people who run this suite hit it most sessions, so the row is "most users, frequently". The letter lands on =[#C]= either way, but the input was wrong and the matrix is only worth anything if its inputs are measured. + +Suspected cause: the check clicks the 3x size chip, calls =scrollIntoView=, waits a fixed 200ms, then reads =getBoundingClientRect= and dispatches the drag against those coordinates. If the zoom relayout or the smooth scroll hasn't settled, the rect is stale and the press lands off the fader — so the drag is a no-op and the readout never moves. The other timing-sensitive checks share the same fixed-sleep shape. + +*Do not fix this by raising the sleep.* That hides the race rather than removing it and leaves the check failing again on a slower run. Wait on the actual condition instead: poll until the rect stops changing between frames, or assert the press landed on the fader before dispatching the drag (the probes' own README already warns that a =find()= miss dispatches into nothing and reports as a widget bug). + +Why it matters beyond the annoyance: a gate that cries wolf gets its real failures ignored, and this suite is the only thing standing between the gallery and a silent regression. + +Recurrences: 2026-07-18 batch-6 gate (first run, passed 3 reruns); 2026-07-18 batch-9 gate (first cold run, =level 68 -> level 68=, passed 2 reruns). Both were the session's first probe run — consistent with the stale-rect theory (cold-start relayout settling slower than the fixed 200ms sleep). + +** TODO [#C] Maint live-refresh hairline replacement :feature:maint: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +From the roam inbox (routed 2026-07-13): the memory-killer section seemed to update too often, and "it's a bit unclear what the line is doing; consider something else." Diagnosis (2026-07-14): the data cadence is already the requested 3s (gui live tier, _LIVE_SECONDS); the perceived churn is the live-refresh hairline — the 2px bar under the live sections that drains full-to-empty over each 3s window, redrawn at 150ms (gui._hair_tick, viewmodel.refresh_fraction). It exists to tell a stale board from a frozen one (2026-07-09), but it reads as constant unexplained motion. Design call for Craig: replace the draining line with something whose meaning is legible — candidates: a dot that blinks once per refresh, a "3s" age caption that only appears when refresh is overdue, slowing the drain redraw, or dropping the indicator on live tiers and keeping it only when data goes stale. Keep the stale-vs-frozen distinguishability that motivated the hairline. + +** TODO [#C] Net panel speedtest history :feature:dotfiles:network: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +From the roam inbox (routed 2026-07-13): the networking panel should track speedtests over time with appropriate info. Shape: persist each SPEED TEST result (timestamp, down/up, latency, server) to a small local store and surface history in the net panel. Design questions for work time: retention window, which fields matter, and presentation within the panel's ~400px width (recent-results list vs trend readout). Point-in-time results exist today; the gap is comparison across days and venues. + +** TODO [#C] zfs base VM image build failure: ZFS DKMS module missing :bug:zfs: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +=FS_PROFILE=zfs make test-vm-base= fails inside the VM at initramfs time: archangel reports "ZFS module not found! DKMS build may have failed" against the installed kernel (linux-lts 6.18.38 at the 2026-07-08 attempt). Consequences: the maint scenario harness's zfs lane (Phase 12) is filtered but unexercised, and a real zfs bare-metal install via archangel would plausibly hit the same wall. Priority per the bug matrix: Major severity (zfs install path broken) × some-users-sometimes = P3. When fixed, run =FS_PROFILE=zfs bash scripts/testing/run-maint-scenarios.sh --list= and add zfs scenario files (zpool scrub / autotrim / snapshot destroy) to the harness. +*** 2026-07-14 Tue @ 01:40:48 -0500 Diagnosed: OpenZFS/kernel version skew, blocked on archzfs +Reproduced in ~1 minute of install: =dkms install zfs/2.3.3 -k 6.18.38-2-lts= exits 1 during pacstrap. Root cause confirmed: OpenZFS 2.3.3's META declares Linux-Maximum 6.15, and the VM installs linux-lts 6.18.38. The first release supporting 6.18 is 2.4.0 (2.4.1 covers 6.19), and archzfs currently serves only zfs-dkms 2.3.3-1 — nothing on our side to fix. Unblock condition: archzfs publishes zfs-dkms ≥2.4.0; recheck with =curl -s https://archzfs.com/archzfs/x86_64/ | grep -o 'zfs-dkms-[0-9.]*'=, then rerun =FS_PROFILE=zfs make test-vm-base=. + +** TODO [#C] Dotfiles stow conflicts: first-launch risk + restow directory handling :bug:dotfiles: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +*** 2026-07-14 Tue @ 00:51:51 -0500 Ratio calibre check passed; waypaper canonical decided (dark-lion) +Ratio's ~/.config/calibre is a directory symlink into the dotfiles repo (stow folded the whole dir), so the first-launch gap never existed there — check closed. Craig decided dark-lion.jpg is the canonical waypaper wallpaper; the repo config.ini updated from the that-one-up-there.jpg placeholder (the file is skip-worktree volatile, unskipped for the commit and re-flagged). Remaining: when velox is back online, run make conflicts / make reset there so its old conflict copy clears against the updated repo. +*** 2026-07-02 Thu @ 17:30:00 -0400 Shipped the Makefile hardening + first-launch guard (dotfiles 42a82d2) +The solo-able subset landed in the speedrun. =make conflicts <de>= is the loud first-launch guard: dry-runs all tiers, parses all four stow error shapes (plain file conflict, foreign symlink, dir-over-file, and restow's unstow_contents non-directory ERROR), lists each blocker with a directory/foreign-symlink marker, exits 1 when any exist. =make reset= now pre-clears the directory and foreign-symlink blockers =--adopt= aborts atomically on (removals printed; repo version wins per the target's contract), then adopts + git-checkouts as before. =make restow='s overwrite path switched rm -f → rm -rf so directory conflicts clear. 8 sandbox tests drive the real Makefile against a throwaway HOME (44 suites green). Also verified on velox: the whereami and mpd-playlists conflicts noted in this task were already hand-converted 2026-06-29 — =make conflicts hyprland= reports clean live. REMAINING (deferred per Craig's speedrun pre-flight): the waypaper canonical decision (live velox dark-lion.jpg vs repo that-one-up-there.jpg) and the ratio calibre-symlink check (ratio paused). +From the velox calibre incident (2026-06-27, note in ~/.dotfiles/inbox/processed/): calibre was launched before =make stow= ran, wrote its own default config into =~/.config/calibre/=, and silently blocked its own stow — it ran on factory defaults while the rest of common/ stowed fine. General pattern: any GUI app that auto-creates config on first run, launched before stow, blocks its own stow the same way. Velox was repaired by hand (=ln -srf= symlinks byte-identical to =stow --no-folding= output). + +Remaining work (re-graded C 2026-07-02 — the first-launch risk and the Makefile handling shipped in the speedrun; what's left is a decision and a paused-machine check): +- Waypaper canonical decision (Craig): live velox has =dark-lion.jpg=, repo has =that-one-up-there.jpg= (placeholder?). Decide the canonical, then =make reset= clears it. +- Ratio check: whether =~/.config/calibre/*.json= on ratio are symlinks or real files — same first-launch gap likely if calibre ever launched there before stow. On the ratio trip list. + +** TODO [#C] Waybar collapse control: replace the triangle glyph :feature:waybar: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +From the 2026-07-04 roam capture. The waybar collapse mechanism (click the triangle, the bar sections redisplay shortened) works, but the triangle glyph doesn't match the instrument-console aesthetic the panels now use. Replace it with something in keeping with the console look. Aesthetic decision — bring Craig two or three concrete glyph/style options (a machined chevron, a console-key style expander, an engraved caret) before wiring. Dotfiles waybar config (handled per the archsetup-owns-dotfiles rule). Raised alongside the net-panel/audio speedrun; deferred from it because the glyph choice is a taste call. + +** TODO [#C] Net panel: driver-health diagnostic tier :feature:network: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-14 +:END: +Follow-up from the 2026-07-04 net-panel hardening speedrun (Craig's cj question on the no-WiFi item). The shipped no-wifi-hardware verdict covers "no adapter at all." This tier covers "adapter present but the driver is wedged": read-only health signals — =ip link= (device present but no-carrier / down), =dmesg= / =journalctl -k= for firmware-load failures, =rfkill= for a hard block, =modinfo= / =lsmod= for the driver module — classified before a generic reset. Remedy actions: a privileged =modprobe -r <mod> && modprobe <mod>= reload of the wifi driver, and a firmware-package pointer when the failure is a missing/failed firmware load. Dotfiles net-package work (handled per the archsetup-owns-dotfiles rule). Design pass first to decide whether it's worth a repair tier vs a needs-user-action pointer. + +** TODO [#C] Voice dictation / speech-to-text input :feature:tooling: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +Push-to-talk dictation that types transcribed speech into the focused Wayland window — usable at any text field, including the Claude Code terminal prompt and Emacs buffers. Claude Code has no built-in voice input; dictation has to happen at the OS level and inject text. Raised 2026-07-03. + +Tool choice is the open decision (needs Craig): =nerd-dictation= (Vosk, lighter, lower accuracy) vs a =whisper.cpp=-based daemon (heavier, higher accuracy, optional GPU). Wayland typing backend is =wtype= or =ydotool=. Scope once chosen: install + model download, a push-to-talk keybind (Hyprland), and an autostart entry; fold into archsetup so it lands on both daily drivers. Consider an Emacs-native path (=whisper.el=) as a complement for in-buffer dictation. + +** TODO [#C] Fix install errors surfaced by the 2026-05-11 VM test run +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +*** 2026-06-28 Sun @ 13:29:29 -0400 Audit reconcile: 2026-06-28 btrfs+zfs runs reproduce the same residual set +Newer full runs landed since the 2026-06-11 reconcile below: the 2026-06-25 zfs run (Testinfra 96/0) and the 2026-06-28 btrfs+zfs runs (97/0, "zero attributed issues"). The residual four were NOT fixed and reproduce unchanged: =enabling firewall= (archsetup:1496-1498, carries a VM-kernel note), =enabling gamemode for user= (archsetup:2221, non-critical), and =tidaler (AUR)=. Zero archsetup-attributed Testinfra issues across both profiles confirms these are environment / non-critical, not archsetup bugs. Bare-metal confirmation of the firewall pair is still the open thread. + +*** 2026-06-15 Mon @ 23:53:21 -0500 Audit reconcile: latest VM run (2026-06-11) confirms the surviving error set +The most recent VM run (=test-results/20260611-113904/=) carries four error-summary entries: =enabling firewall= + =verifying firewall is active= (the iptables/nf_tables "Could not fetch rule set generation id" pair, still unconfirmed on bare metal), =enabling gamemode for user= (non-critical), and =tidaler (AUR)=. The earlier fontconfig/dconf fixes held — none reappear. So the count is down from the 7→6 anchor below to four, all of them the known-residual items already itemized. +Errors logged during the VM install. Status as of the 2026-05-11 18:36 run (=test-results/20260511-183643/archsetup-output.log=) after the =48c9439= fontconfig/dconf fix: 7 → 6. +- refreshing font cache — RESOLVED in =48c9439= (now installs =fontconfig= before calling =fc-cache=). +- configuring GTK file chooser — RESOLVED in =ecab29f= (switched to a system-wide dconf db at =/etc/dconf/db/site.d/=; needs no session bus during install). +- configuring GNOME interface settings in dconf — RESOLVED in =ecab29f= (same fix as the GTK file chooser above). +- enabling firewall — exit 1: =iptables v1.8.13 (nf_tables): Could not fetch rule set generation id: Invalid argument=. Still present in the 18:36 run; likely a VM-kernel/nf_tables artifact — confirm on bare metal before treating as an archsetup bug. +- verifying firewall is active — exit 1 (follow-on from the firewall-enable error). +- enabling gamemode for user — exit 1 → step "gaming" FAILED — non-critical. +- tidaler (AUR) — logged in the error summary with exit code 0 (odd; logging quirk or transient AUR build noise?). +Also seen in the 18:36 run's log-diff (post-install systemd noise, probably VM-environment): =pam_systemd … CreateSession failed= / =logind: Failed to start session scope … Permission denied=, and =Failed to start Proton VPN Daemon= (no VPN config in the test VM). + +*** 2026-05-19 Tue @ 13:18:56 -0500 Fixed AUR exit-0 logging bug at the root +Root cause was in =retry_install=: =last_exit_code=$?= ran AFTER =if eval ...; then return 0; fi=. Bash defines an if-compound's exit status as zero when no condition tested true, so a failing eval's exit code got overwritten with 0 before reaching =error_warn=. Fix in =8221c54=: capture =$?= from =eval= directly into a local var, then compare against the captured value in the if. VM-verified in =test-results/20260519-115318/=: =mkinitcpio-firmware (AUR)= and =tidaler (AUR)= now report =error code: 1= (yay's actual exit) instead of the misleading =error code: 0=. The same packages still appear in the summary because yay returns non-zero when sub-deps fail to build (e.g. =aic94xx-firmware=), but the codes are accurate now. If the underlying sub-dep failures stay noisy, that's a separate concern — open a new task. + +*** 2026-05-16 Sat @ 09:00:41 -0500 AI Response: Surfaced the expanded AUR-exit-0 pattern +2026-05-16 07:40 VM run passed (52/0/5) with the same warning profile as the 2026-05-11 18:36 run. Error count went 7 → 13: 5 fixed/unchanged, +5 new AUR-exit-0 entries (broadens the existing tidaler item into the dedicated =[#B]= subtask above), +1 genuinely new error in =setting up emacs configuration files= (=git pull= ran in =~/.emacs.d= which existed from stow but had no =.git=). Patched =archsetup:1932-1945= with a three-branch check: clone if missing/empty, pull if =.git= exists, =git init=/=fetch=/=checkout= in place if the dir came from stow. + +*** 2026-05-19 Tue @ 01:25:26 -0500 Verified the b9907c7 emacs-stow fix end-to-end +=make test= 21:44 → 22:29 (42 min), =test-results/20260518-214516/=. 52/0/5, =ArchSetup Exit Code: 0=. The third-branch path fired correctly — install log =archsetup-2026-05-18-21-45-46.log:14358-14365= shows =From https://git.cjennings.net/dotemacs= → =[new branch] main -> origin/main= → =Reset branch 'main'= → =branch 'main' set up to track 'origin/main'=. No exit-128, no =fatal: not a git repository=. Error Summary down to 7 (was 13 on 2026-05-16); the emacs entry is gone. AUR exit-0 logging triggered for 2 packages this run (mkinitcpio-firmware, tidaler) vs 6 on 2026-05-16 — same bug class, fewer triggers, still tracked under =[#B] AUR exit-0 logged as error=. Issue Attribution: 1 ARCHSETUP entry (Proton VPN Daemon failed — known VM-no-VPN-config artifact). Cleanup ran clean via the normal path. + +** TODO [#C] Osbot camera configuration :chore: +Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned device setup: "configure osbot camera." Scope to define at pickup (device model, what "configure" covers — kernel module, v4l settings, default framing). +** TODO [#C] Re-check python-lyricsgenius --skipinteg workaround :chore:solo: +:PROPERTIES: +:LAST_REVIEWED: 2026-07-09 +:END: +archsetup installs =python-lyricsgenius= with =--mflags --skipinteg=, skipping makepkg integrity + PGP checks — a workaround originally for an expired-signature issue upstream (surfaced by the 2026-06-23 --noconfirm audit). Periodically test whether the cause has cleared: if a plain =aur_install python-lyricsgenius= builds without complaint, drop the =--skipinteg= workaround. Removal needs a real AUR build to confirm, so it isn't a blind change. + +*** 2026-07-14 Tue @ 01:40:48 -0500 Rechecked: still needed, same cause +Fresh AUR clone, =makepkg --verifysource= on 3.7.0-1 (PKGBUILD unchanged): tarball passes, =LICENSE.txt= still FAILS its b2sum — the github-master pin, structural as diagnosed 2026-06-24. =--skipinteg= stays. + +*** 2026-07-02 Thu @ 05:08:37 -0400 Rechecked: still needed, same cause +=makepkg --verifysource= on 3.7.0-1: tarball passes, =LICENSE.txt= still FAILS +its b2sum — the PKGBUILD still pins a hash for a file fetched from github +master. Structural, as diagnosed 2026-06-24; =--skipinteg= stays. + +*** 2026-06-24 Wed @ 17:55:34 -0400 Rechecked: still needed, but the cause changed +Ran =makepkg --verifysource= on the current AUR PKGBUILD (3.7.0-1). The package tarball =lyricsgenius-3.7.0.tar.gz= now passes its b2sum — the original expired-PGP-signature problem is gone (the PKGBUILD no longer carries any =validpgpkeys=). But integrity still FAILS, on a different file: =LICENSE.txt=, which the PKGBUILD fetches from the project's github master and pins a b2sum for. github master is a moving target, so that b2sum drifts and =--skipinteg= is still required. This is structural (not a transient upstream fix away), so it likely won't clear until the maintainer pins the LICENSE to a tagged release. Updated the archsetup comment to the real cause. Keep rechecking, but lower expectations of it clearing. + +** TODO [#C] Review theme config architecture for dunst/fuzzel +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +The active dunst config is stowed from dotfiles/common/ but theme templates +live in dotfiles/hyprland/.config/themes/. set-theme copies the templates to +the stowed locations at runtime, so edits to the common file get overwritten +on theme switch. This split between stowed configs and theme templates is +error-prone — changes must be made in both places. Consider: +- Having set-theme be the single source of truth (remove common dunstrc from stow) +- Or symlinking the stowed config to a theme-managed location +- Same situation applies to fuzzel.ini +The goal is a single place to edit each config, not two. + +** TODO [#C] Review current tool pain points annually +:PROPERTIES: +:LAST_REVIEWED: 2026-07-06 +:END: +Once-yearly systematic inventory of known deficiencies and friction points in current toolset + +** TODO [#D] Installer + scripts refactor opportunities :refactor: +Grading: no behavior change; parking lot. 10 refactors remain from the sentry audit — duplicated GPU-modalias scan, triple hand-rolled retry loop, stow x4, display_server/window_manager dispatch dup, Maia ELO range x3, per-script log helpers, GRUB/snapper/fsck sed clusters, waybar-battery positional sed. Full list with line numbers in [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (High/Medium/Low tagged). Pull individual ones out as their own tasks when tackled. The system-mutation sed clusters (snapper/fsck/GRUB/waybar) want characterization coverage before any rewrite. +*** 2026-07-20 Mon @ 16:35:00 -0500 Extracted validate_yesno and the NVIDIA_MIN_DRIVER constant +In 67d0b6e: the four yes/no config-validation blocks collapse into validate_yesno (TDD), and the driver-floor literal 535 becomes NVIDIA_MIN_DRIVER. The safe, purely-testable slice; the remaining 10 (structural / system-mutation) stay parked above. +** TODO [#D] Test-framework + prototype refactor cluster :refactor: +Grading: no behavior change; parking lot. Refactors from the S5-S7 audit, distinct from the installer refactor rollup above. +scripts/testing/run-test.sh + run-test-baremetal.sh duplicate the run/poll/report skeleton and have drifted (VM uses setsid + copy helpers, baremetal uses nohup + hand-rolled sshpass scp) — extract the shared core so baremetal inherits the sturdier paths; run-maint-nspawn.sh:66 + run-maint-scenarios.sh:78 duplicate the transport-independent _scenario_var/_validate_scenario/run_scenario (a sourced lib/maint-scenario.sh); run-test.sh:251,265 uses two different mechanisms (pgrep vs ps|grep) for the same liveness check; docs/prototypes/gen_tokens.py:78 repeats the section-iteration skeleton across four emitters; gallery-widget.el:95,136 hardcodes SVG arc/hub path strings that duplicate the cx/cy/radius geometry (dial desyncs silently on a constant change); gallery-widget.el:72,84 leans on the private svg--append. See findings doc (S5, S6, S7). +** TODO [#D] Net doctor vNext :feature:dotfiles:network: +Deferred from the [[file:docs/specs/2026-07-11-net-doctor-expansion-spec.org][net doctor expansion spec]] (IMPLEMENTED, v1 shipped): event-log correlation for the flaky/drops cluster (powersave, roaming stalls, USB autosuspend, no-reconnect-after-resume, firmware crashloop drop signatures — needs history a one-shot probe can't see); a DoT/DNSSEC-specific verdict distinguishing "the venue resolver mangles DNSSEC" from the generic DNS-not-resolving; per-profile autoconnect/duplicate-profile hygiene. + +** TODO [#D] Bt doctor vNext :feature:dotfiles:bluetooth: +Deferred from the [[file:docs/specs/2026-07-11-bt-doctor-expansion-spec.org][bt doctor expansion spec]] (IMPLEMENTED, v1 shipped): the stale-bond re-pair-offer signature (v1 keeps re-pair strictly user-initiated; the signature must be designed and validated before the doctor ever offers it); a connection-parameter/coexistence hint tail for a "keeps dropping" verdict; the bt-audio-profile expansion beyond the current a2dp repair (codec fallback, default-sink, absolute-volume) — wants coordination with the audio doctor so the two panels don't claim the same A2DP/HFP diagnosis with divergent verdicts. + +** TODO [#D] Widget catalogue vNext :feature:design: +Deferred from the [[file:docs/specs/2026-07-12-component-generation-spec.org][component-generation spec]] (DRAFT): framework wrappers for the web library (React or otherwise — demand-gated, none until a real framework consumer exists); Level-2/3 widget codegen if the spec's Phase 5 decision point says go (a go spawns its own spec); ports beyond the demand matrix as new consumers appear. + +** TODO [#D] Maintenance console vNext :feature: +Deferred from the [[file:docs/specs/2026-07-07-maintenance-console-spec.org][spec]] (v1 ships determinate remedies only): AI/workflow assistance on read-only metrics (the vLater tier — failed-unit diagnosis, journal-error fix suggestions, OOM investigation); SIGKILL escalation for TERM-survivors on the KILL lever; live streaming meters on evidence rows where a count could be a level. + +** TODO [#D] Retention-repair remedy unreachable from the panel GUI :bug:dotfiles: +snapshot_retention_repair (WRITE SANE LIMITS — =snapper -c <config> set-config TIMELINE_CREATE=yes TIMELINE_LIMIT_*=<TOML values>=) sits in the maint remedy table but is wired into no GUI layer (no reference in panel.py, viewmodel.py, or gui.py) — it's reachable only via =maint doctor review= / =maint fix=. Worse, the REVIEW & FIX roster's wording for item-bearing remedies says "per-item — on its subpanel", which for this remedy points at a key that doesn't exist. Found 2026-07-08 while walking the SNAPSHOTS subpanel with Craig. + +Two fix shapes, decide at work time: (1) wire a digest key — e.g. on the timeline row when the config's installed TIMELINE_LIMIT_* drift from the TOML values (needs the probe to read the installed limits for comparison); or (2) keep it CLI-only deliberately and special-case the roster wording for remedies with no panel key ("CLI only — maint fix"). Option 2 is a few lines; option 1 makes limit drift visible on the board, which is the 2026-05-26 pile-up's root cause. Severity minor × rare edge = P4 per the bug matrix. + +** TODO [#D] Consider Customizing Hyprland Animations +Current: windows pop in, scratchpads slide from bottom. + +Customizable animations: +- windows / windowsOut / windowsMove - window open/close/move +- fade - opacity changes +- border / borderangle - border color and gradient angle +- workspaces - workspace switching +- specialWorkspace - scratchpads (currently slidevert) +- layers - waybar, notifications, etc. + +Styles: slide, slidevert, popin X%, fade +Parameters: animation = NAME, ON/OFF, SPEED, BEZIER, STYLE +Speed: lower = faster (1-10 typical) + +Example tweaks: +#+begin_src conf +animation = windows, 1, 2, myBezier, popin 80% +animation = workspaces, 1, 4, default, slide +animation = fade, 1, 2, default +animation = layers, 1, 2, default, fade +#+end_src + +** TODO [#D] Parse and improve AUR error reporting +Parse yay errors and provide specific, actionable fixes instead of generic error messages + +** TODO [#D] Improve progress indicators throughout install +Enhance existing indicators to show what's happening in real-time + ** TODO [#D] Telega coredump recurrence tell :bug:maint: :PROPERTIES: :LAST_REVIEWED: 2026-07-09 @@ -1839,3 +1816,97 @@ CLOSED: [2026-07-19 Sun] Shipped dotfiles 9105361: manage.wifi_radio -> _connect_best_saved activates the strongest in-range saved profile on enable; nothing in range falls back to NM autoconnect. When enabling WiFi, automatically connect to the highest-priority available saved network instead of requiring a panel selection first. +** DONE [#A] Tracked WireGuard private keys in repo — public leak, resolved :bug:security:network: +CLOSED: [2026-07-20 Mon] +Confirmed a live public leak, not just at-risk: git.cjennings.net runs cgit (scan-path=/var/git), so archsetup.git was anonymously cloneable over https. An unauthenticated clone pulled the configs with intact PrivateKeys. Exposed 2026-07-05 (c7b7d16) to 2026-07-20. Regraded to P1/[#A] (public credential exposure, severity-alone carve-out) from the initial [#B]. +Scope was wider than first found: the current 3 configs (assets/wireguard-config/wg-*.conf) plus 7 older ones at the pre-reorg path assets/wireguard/ (switzerland x2, USCALA/USCASF/USDC/USGAAT/USNY) — 10 config files, all with real keys. +Resolution: Craig expired all the Proton WireGuard configs (keys dead). Purged all 10 from every commit with git filter-repo, force-pushed main + v0.5, and ran git gc --prune=now on the server bare repo. Verified via anonymous clone: zero real-key blobs reachable, all old exposed commits gone. Stopped tracking plaintext (gitignore + README, out-of-band configs only). +Follow-ups filed below: harden cgit exposure; installer no longer ships configs. +** DONE [#C] Installer chpasswd unguarded — unloggable primary user :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed (fa3135a): extracted set_user_password, which guards the chpasswd with error_fatal so a failure aborts loudly instead of silently leaving no password. Fake-chpasswd test pins the guard fires on failure and stays quiet on success. +Grading: Major severity (fresh system's primary user can't log in) x rare edge case (chpasswd seldom fails) = P3 = [#C]. +archsetup:1168 runs =echo "$user:$pass" | chpasswd= with no guard, then unsets the password next line; set -e is off (line 21), so a silent failure leaves no password and no log entry. Fix: guard with error_fatal (report + "set it by hand: passwd $user") before unsetting. See findings doc (S2). +** DONE [#C] Installer nvme early module never built into initramfs :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in e0d22bd: extracted ensure_nvme_early_module, which rebuilds the initramfs whenever it changed the conf (regardless of ZFS root) and scopes the presence check to the MODULES line. TDD via tests/installer-steps/test_ensure_nvme_early_module.py. +Grading: Minor severity (module autoload still boots the system) x most-machines (all Craig's ZFS-root boxes) = P3 = [#C]. +archsetup:2910 writes MODULES=(nvme) but the only mkinitcpio -P in boot_ux runs =if ! is_zfs_root=, so on ZFS-root non-Framework machines the early-load hardening is never compiled in. Also archsetup:2918 greps the whole file for "nvme" (not the MODULES line). Fix: rebuild initramfs after the MODULES edit regardless of ZFS; scope the presence grep to =^MODULES=(=. See findings doc (S3). +** DONE [#C] Installer disk-space pre-flight check is fragile :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in aef074f: extracted check_disk_space using df -P (wrap-safe) and a KB comparison (no truncation bias); non-numeric df output falls back to zero so a malformed read aborts loudly. TDD via tests/installer-steps/test_check_disk_space.py. +Grading: Major severity (aborts a valid install) x some (df wraps long device names on a live ISO / device-mapper root) = P3 = [#C]. +archsetup:487 parses =df / | awk 'NR==2'=, which reads the device-name line (empty $4 -> 0 GB) when df wraps; archsetup:488 also integer-truncates the GB compare against the 20 GB floor. Fix: =df -P /= (single-line) or =df --output=avail=; compare in KB to avoid the rounding bias. See findings doc (S1). +** DONE [#C] Installer run_step state + exit-code handling :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 6de55d2: run_step records the state marker whenever the step function returns (a return past error_fatal's exit means only a non-fatal warning is left), added local to run_step/show_status, and captured pacman's real exit in the refresh loop. TDD via tests/installer-steps/test_run_step.py. +Grading: Major severity (resume re-runs steps and can abort on a survivable warning) x some (a step whose last action is a non-fatal failure) = P3 = [#C]. +archsetup:298 marks a step complete only when its function returns 0, but error_warn/run_task return 1, so a non-fatal-failing step never writes its marker and re-runs on resume. Also archsetup:1034 reports =$?= of the =false= test, not pacman's real exit code; and run_step locals (290/318) leak to global scope. Fix: step functions =return 0= explicitly (or gate run_step on a per-step error flag); capture the real exit code; add =local=. See findings doc (S1). +** DONE [#C] cmail password decrypted world-readable before chmod :bug:security:solo:quick:cmail: +CLOSED: [2026-07-20 Mon] +Already fixed in dffecf5 (before this session): decrypt_to_secure wraps the gpg decrypt in a 0077-umask subshell so the file is 0600 from creation, with tests/cmail/ verifying the umask at write time. The task was stale; verified green and closed. +Grading: security carve-out — brief local plaintext exposure of the mail password, requires a concurrent local shell during install; narrow window = low severity = P3 = [#C]. +scripts/cmail-setup-finish.sh:52 gpg-decrypts to ~/.config/.cmailpass at the process umask (often 0644), then chmod 600 on the next line. Fix: =(umask 077; gpg ... --output ...)= or decrypt to a mktemp 0600 file and mv into place (mirror the import-wireguard mktemp -d 0700 pattern). See findings doc (S4). +** DONE [#C] Installer sudoers.pacnew blind copy risks lockout :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in c80e855: extracted replace_sudoers_pacnew, which runs visudo -cf on the pacnew and only copies a validated file (warns and keeps the working sudoers otherwise). TDD via tests/installer-steps/test_replace_sudoers_pacnew.py. +Grading: Major severity (a malformed sudoers locks out privilege escalation) x rare edge case = P3 = [#C]. +archsetup:1146 does =[ -f /etc/sudoers.pacnew ] && cp /etc/sudoers.pacnew /etc/sudoers= with no validation, right before the NOPASSWD rule at 1183. Fix: =visudo -cf /etc/sudoers.pacnew && cp ... || error_warn=. See findings doc (S2). +** DONE [#C] WireGuard import leaves full-tunnel VPN live on failure :bug:solo:network: +CLOSED: [2026-07-20 Mon] +Fixed in 36daf76: the down now runs before the rename modify (targets the stable UUID), so a failed modify under set -e can't leave a live full-tunnel VPN. Added a connection-down case to fake-nmcli and two ordering tests. +Grading: Major severity (all traffic silently routed through Proton until manual cleanup) x rare (nmcli modify failure) = P3 = [#C]. +scripts/import-wireguard-configs.sh:51-62 imports (which brings the 0.0.0.0/0 tunnel up), renames, then deactivates; under set -e a failed modify aborts before the down, leaving the tunnel live. Fix: bring the connection down right after parsing the UUID, before the rename. See findings doc (S4). +** DONE [#C] net-scenarios diagnose failure exits green :bug:test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in cf211cd: a diagnose miss sets a per-scenario rc carried to the subshell exit, so the run fails honestly while still running fix + assert. New harness at tests/net-scenarios/ drives the real script with stubbed ssh/rsync/jq. +Grading: Major severity (a net-doctor diagnosis regression is reported as a passing run — false green on a diagnostic tool) x rare edge case (only when a diagnosis regresses and this first-draft harness is relied on) = P3 = [#C]. +scripts/testing/run-net-scenarios.sh:103 — the scenario_diagnose_expect else-branch prints fail "...diagnose did NOT name it" but never forces a non-zero subshell exit, so ( ... ) || fails=... leaves fails unincremented and the script prints "all scenarios passed" + exit 0. Fix: exit 1 in that branch like the other two checks. See findings doc (S5). +** DONE [#C] pacman-hook-order test is a tautology :test:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in 1b7236b: the test now extracts the hook filenames the installer writes and compares them against the stock 60-mkinitcpio-remove name (pacman's filename ordering is the real invariant, not source position). Mutation-verified: a 05->70 rename fails the new compare where the old literal compare stayed true. +Grading: Major severity (guards boot-critical hook ordering — a reorder that removes the current initramfs without a rebuild is unbootable, and this test would ship it green) x rare (hook order rarely changes) = P3 = [#C]. +tests/installer-steps/test_pacman_hook_order.py:20 — the two assertLess calls compare string literals ("05..." < "60..."), a constant ASCII fact always true regardless of file content; the ordering the test exists to protect is never measured. Only the assertIn presence checks do real work. Fix: assert on positions — text.index("05-zfs-snapshot.hook") < text.index("60-mkinitcpio-remove.hook") (and the guard hook). See findings doc (S6). +** DONE [#C] Add inetutils to install base :feature:solo:quick:network: +CLOSED: [2026-07-20 Mon] +Already done in 1115543 (earlier today): inetutils sits in install_required_software, with tests/installer-steps/test_required_software.py pinning it (test_installs_inetutils_for_ftp, green). The task was stale; verified and closed. The next full VM run covers the install-path verification. +Original context: TRAMP's /ftp: method needs =/usr/bin/ftp= (GNU inetutils); dirvish has an FTP quick-access entry. Installed manually on ratio 2026-07-14. From .emacs.d handoff 2026-07-14-1751. +** DONE [#D] Installer resume-idempotency cluster :bug:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 8917f2f: extracted crontab_append_once (dedup guard), zfs_scrub_timer_units (one timer per pool, warn on none instead of @.timer), and enable_user_service (wants-symlink; gamemode now uses it and syncthing folds into the shared helper). TDD via tests/installer-steps/test_idempotency_cluster.py. +Grading: Minor severity x rare edge case (re-run after a mid-step failure) = P4 = [#D]. Group of small non-idempotent / wrong-target spots. +crontab log-cleanup line duplicates on resume (archsetup:1713 — guard on absence); zfs scrub timer picks an arbitrary pool via =head -1= and yields =@.timer= when empty (archsetup:1857); gamemode enabled via =systemctl --user= which the script itself documents fails at install time (archsetup:2419 — use the manual wants-symlink like syncthing). See findings doc (S2, S3). +** DONE [#D] Installer unguarded chmod/cp after non-fatal ops :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in dd41036: extracted install_executable (guarded cp + chmod +x) for the two zfs scripts; guarded the two hypr-live-update-guard chmods inline with error_warn. TDD via tests/installer-steps/test_install_executable.py. +Grading: Minor severity x rare edge case (only when a preceding non-fatal cp/clone failed) = P4 = [#D]. +With set -e off, unguarded chmod/cp hit missing/partial files silently: hypr-live-update-guard chmods (archsetup:2108/2144), zfs-replicate cp (archsetup:1820) leaving a service with a dead ExecStart, zfs-pre-snapshot cp (archsetup:1943) leaving a broken pacman hook. Fix: wrap each in =(...) >> log 2>&1 || error_warn=. See findings doc (S2, S3). +** DONE [#D] normalize-notify-sounds temp/atomicity can corrupt tracked file :bug:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in a29769e: resolves the real target via readlink -f, stages the temp beside it, guards on a non-empty encode, and atomically mv's into place (preserving the stow symlink); an EXIT trap cleans a leaked temp. TDD via tests/normalize-notify/ with fake ffmpeg. +Grading: Minor severity (corrupts a repo-tracked sound file, recoverable via git) x rare (ffmpeg failure/interrupt) = P4 = [#D]. +scripts/normalize-notify-sounds.sh:39-46 has no EXIT trap on the mktemp and does =cat "$tmp" > "$f"= (truncate-first) where $f is a stow symlink into the repo; a zero-byte/failed encode writes a corrupt file. Fix: EXIT trap; =[ -s "$tmp" ]= guard; write $f.tmp and overwrite on success. See findings doc (S4). +** DONE [#D] VM test-framework robustness cluster :bug:test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 866d327: profile-suffixed PID/monitor/serial paths, kill_qemu reaps-or-polls to death before the snapshot restore, debug-vm uses DISK_PATH, and both runners report an honest ARCHSETUP_COMPLETED marker instead of a fake exit code. TDD via tests/vm-framework/test_vm_utils.py (suffix red->green; kill_qemu as a contract pin). +Grading: Minor severity x rare edge case (each fires only in a narrow test-harness path) = P4 = [#D]. Group of four small framework bugs from the S5 audit. +scripts/testing/debug-vm.sh:49 hardcodes the btrfs base disk, ignoring the profile-correct DISK_PATH from init_vm_paths (FS_PROFILE=zfs boots the wrong base or fatals); lib/vm-utils.sh:284 kill_qemu -9's and deletes the PID file without waiting, so a force-kill restore races the dying qemu's qcow2 lock and silently leaves the base image dirty (fix: wait for the PID); lib/vm-utils.sh:69 leaves PID_FILE/MONITOR_SOCK/SERIAL_LOG un-suffixed so parallel btrfs+zfs runs collide (fix: suffix by FS_PROFILE like DISK_PATH); run-test.sh:287 (and run-test-baremetal.sh:234) reports a completion-marker grep as ARCHSETUP_EXIT_CODE, not the installer's real exit — misleading since the installer runs set -e off and can error then still write the marker (fix: rename + capture the true status). Testinfra remains the real pass/fail backstop. See findings doc (S5). +** DONE [#D] Gallery-widget prototype elisp bugs :bug:design:solo:quick: +CLOSED: [2026-07-20 Mon] +Fixed in 552736e: shared clamp feeds needle + readout (150 renders 100%), explicit cl-lib require, and gallery-widget--source-dir with a default-directory fallback. TDD: 3 new ERT tests (clamp red->green; the other two land as pins since svg.el transitively loads cl-lib). +Grading: Minor severity x rare edge case (out-of-range input / cold byte-compile / interactive re-eval) = P4 = [#D]. Prototype code, all three Minor. +docs/prototypes/gallery-widget.el:139 renders the readout from the unclamped value while the needle clamps 0-100, so at value 150 the needle pins at +60 degrees but the text reads "150%" (fix: clamp once, format both from it); :69 calls cl-loop without (require 'cl-lib) — works only via the autoload cookie, bites on a cold byte-compile (fix: add the require); :29 computes its dir from (or load-file-name buffer-file-name), both nil on interactive re-eval outside a load/file buffer (fix: fall back to default-directory). See findings doc (S7). +** DONE [#D] Audit test-quality cluster (Python + elisp) :test:solo: +CLOSED: [2026-07-20 Mon] +Fixed in 179fbd5 (plus 552736e for the gauge-level clamp test): socket check via find -type s, gen_tokens degenerate case pinned exactly as characterization, tick count as direct occurrences, and write-svg covered. All five items dispositioned. +Grading: no runtime behavior change; test-suite quality. Group of five weak/missing tests from the S6/S7 audit. +scripts/testing/tests/test_desktop.py:96 passes a shell glob to `test -S`, which breaks on zero or multiple sockets (masked today because the test always skips); tests/gallery-tokens/test_gen_tokens.py:181 asserts properties too weak to notice the marker output is garbled (impossible input, so low); tests/gallery-widgets/test-gallery-widget.el:77 counts ticks via split-string + cl-count-if :start 1 (a coincidence of split semantics, not a match count); :47 tests the needle-angle helper's clamp but never the rendered readout at an out-of-range value (exactly why the S7 readout/needle bug ships green — add a gauge-level boundary case); :159 leaves gallery-widget-write-svg uncovered (add a Normal write-to-temp case). See findings doc (S6, S7). +** DONE [#B] Installer GRUB_CMDLINE overwrite drops boot params :bug:solo: +CLOSED: [2026-07-21 Tue] +Fixed in f9da097: update_grub_cmdline merges the current value with archsetup's tokens (existing tokens survive, same-key conflicts resolve to archsetup's value) behind a refuse-to-write safety check, via awk + mv with a backup_system_file first. TDD via tests/installer-steps/test_grub_cmdline.py (8 cases incl. cryptdevice/resume/zfs survival and idempotence). +Grading: Critical severity (unbootable) x some-users-sometimes (machines whose base install set a cryptdevice=/resume=/zfs= cmdline param) = P2 = [#B]. +archsetup:3054 rewrites the whole GRUB_CMDLINE_LINUX_DEFAULT line with a fixed string; nothing re-adds a pre-existing cryptdevice/resume/zfs token, so grub-mkconfig (3059) can bake an unbootable config. Fix: read the current value and append only the missing tokens; assert any pre-existing boot-critical token survives before grub-mkconfig. See [[file:docs/design/2026-07-19-sentry-code-findings.org][sentry code findings]] (S3). +** DONE [#C] Maint status wall copy buttons :feature:maint:dotfiles: +CLOSED: [2026-07-21 Tue] +Shipped in dotfiles 8bc79ba per Craig's calls (one global button, rendered text): COPY on the doctor row serializes every category band via the same card_spec the GUI renders, through panelkit clipboard. TDD tests/maint/test_status_copy.py, full dotfiles make test green, inbox note sent. Live check pending: open the maint panel, press COPY, paste. +Craig's roam capture 2026-07-20, routed via .emacs.d sentry inbox-zero as archsetup-owned UI work. Dotfiles maint panel work; archsetup drives it end-to-end per the standing rule. diff --git a/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt b/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt new file mode 100644 index 0000000..38ba88d --- /dev/null +++ b/working/hyprland-layoutmsg-crash/crash-report-2026-03-07-pid1812.txt @@ -0,0 +1,184 @@ +-------------------------------------------- + Hyprland Crash Report +-------------------------------------------- +All these computers... + +Hyprland received signal 11(SEGV) +Version: 4b07770b9ef1cceb2e6f56d33538aaffb9186b9c +Tag: v0.54.1 +Date: Tue Mar 3 21:06:41 2026 +Flags: + +System info: + System name: Linux + Node name: ratio + Release: 6.18.16-1-lts + Version: #1 SMP PREEMPT_DYNAMIC Wed, 04 Mar 2026 18:09:01 +0000 + +GPU: + c3:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev c1) + + +os-release: + NAME="Arch Linux" + PRETTY_NAME="Arch Linux" + ID=arch + BUILD_ID=rolling + ANSI_COLOR="38;2;23;147;209" + HOME_URL="https://archlinux.org/" + DOCUMENTATION_URL="https://wiki.archlinux.org/" + SUPPORT_URL="https://bbs.archlinux.org/" + BUG_REPORT_URL="https://gitlab.archlinux.org/groups/archlinux/-/issues" + PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" + LOGO=archlinux-logo + +Libraries: +Hyprgraphics: built against 0.5.0, system has 0.5.0 +Hyprutils: built against 0.11.0, system has 0.11.0 +Hyprcursor: built against 0.1.13, system has 0.1.13 +Hyprlang: built against 0.6.8, system has 0.6.8 +Aquamarine: built against 0.10.0, system has 0.10.0 + +Backtrace: + # | Hyprland(_Z12getBacktracev+0x61) [0x5610bf4fa961] + getBacktrace() + ??:? + #1 | Hyprland(_ZN13CrashReporter18createAndSaveCrashEi+0xcd6) [0x5610bf45b666] + CrashReporter::createAndSaveCrash(int) + ??:? + #2 | Hyprland(+0x27d31e) [0x5610bf3a931e] + std::__format::_Formatting_scanner<std::__format::_Sink_iter<char>, char>::_M_format_arg(unsigned long) + ??:? + #3 | /usr/lib/libc.so.6(+0x3e2d0) [0x7f7a8be4d2d0] + ?? + ??:0 + #4 | Hyprland(+0x1f62fe) [0x5610bf3222fe] + std::__throw_bad_variant_access(unsigned int) + ??:? + #5 | Hyprland(_ZN6Layout10CAlgorithm9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0x85) [0x5610bf575df5] + Layout::CAlgorithm::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #6 | Hyprland(_ZN6Layout6CSpace9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0x37) [0x5610bf57aa07] + Layout::CSpace::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #7 | Hyprland(_ZN6Layout14CLayoutManager9layoutMsgB5cxx11ERKSt17basic_string_viewIcSt11char_traitsIcEE+0xce) [0x5610bf57275e] + Layout::CLayoutManager::layoutMsg[abi:cxx11](std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #8 | Hyprland(_ZN15CKeybindManager9layoutmsgENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x4f) [0x5610bf5a1cbf] + CKeybindManager::layoutmsg(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) + ??:? + #9 | Hyprland(_ZNSt17_Function_handlerIF15SDispatchResultNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEPS7_E9_M_invokeERKSt9_Any_dataOS6_+0x63) [0x5610bf5ceed3] + std::_Function_handler<SDispatchResult (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), SDispatchResult (*)(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&) + ??:? + #1 | Hyprland(+0x306750) [0x5610bf432750] + CHyprCtl::CHyprCtl() + ??:? + #11 | Hyprland(_ZNSt17_Function_handlerIFNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE20eHyprCtlOutputFormatS5_EPS7_E9_M_invokeERKSt9_Any_dataOS6_OS5_+0x6b) [0x5610bf44f17b] + std::_Function_handler<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (*)(eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, eHyprCtlOutputFormat&&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&) + ??:? + #12 | Hyprland(_ZN8CHyprCtl8getReplyENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x57e) [0x5610bf42d4de] + CHyprCtl::getReply(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) + ??:? + #13 | Hyprland(+0x308772) [0x5610bf434772] + CHyprCtl::CHyprCtl() + ??:? + #14 | /usr/lib/libwayland-server.so.0(wl_event_loop_dispatch+0x1d2) [0x7f7a8cafa182] + ?? + ??:0 + #15 | /usr/lib/libwayland-server.so.0(wl_display_run+0x37) [0x7f7a8cafc297] + ?? + ??:0 + #16 | Hyprland(_ZN17CEventLoopManager9enterLoopEv+0x2c1) [0x5610bf631811] + CEventLoopManager::enterLoop() + ??:? + #17 | Hyprland(main+0x1476) [0x5610bf32b756] + main + ??:? + #18 | /usr/lib/libc.so.6(+0x276c1) [0x7f7a8be366c1] + ?? + ??:0 + #19 | /usr/lib/libc.so.6(__libc_start_main+0x89) [0x7f7a8be367f9] + ?? + ??:0 + #2 | Hyprland(_start+0x25) [0x5610bf393d35] + _start + ??:? + + +Log tail: +DEBUG ]: Hyprctl: new connection from pid 6904 +DEBUG ]: Hyprctl: new connection from pid 6918 +DEBUG ]: Hyprctl: new connection from pid 6920 +DEBUG ]: Hyprctl: new connection from pid 6923 +DEBUG ]: CConfigManager: file /home/cjennings/.config/hypr/hyprland.conf modified, reloading +DEBUG ]: Using config: /home/cjennings/.config/hypr/hyprland.conf +DEBUG ]: ApplyConfigToKeyboard for "video-bus", hasconfig: 0 +DEBUG ]: Not applying config to keyboard, it did not change. +DEBUG ]: ApplyConfigToKeyboard for "power-button", hasconfig: 0 +DEBUG ]: Not applying config to keyboard, it did not change. +DEBUG ]: ApplyConfigToKeyboard for "keychron-q6-pro-keyboard", hasconfig: 0 +DEBUG ]: Not applying config to keyboard, it did not change. +DEBUG ]: Applied config to mouse keychron-q6-pro-mouse, sens 0.00 +DEBUG ]: Applied config to mouse keychron-q6-pro-keyboard-1, sens 0.00 +WARN ]: No rule found for DP-4, trying to use the first. +DEBUG ]: Applying monitor rule for DP-4 +DEBUG ]: Monitor DP-4: requested preferred, using preferred mode 3440x1440@59.97Hz +DEBUG ]: output DP-4 succeeded basic test on format DRM_FORMAT_XRGB8888 +DEBUG from aquamarine ]: drm: Modesetting DP-4 with 3440x1440@59.97Hz +DEBUG ]: Monitor DP-4 data dump: res 3440x1440@59.97Hz, scale 1.00, transform 0, pos 0x0, 10b 0 +DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads +WARN ]: FIXME: color management protocol is enabled and outputs changed, check preferred image description changes +DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads +WARN ]: No rule found for DP-4, trying to use the first. +DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads +DEBUG ]: arrangeMonitors: 1 to arrange +DEBUG ]: arrangeMonitors: DP-4 auto [0, 0] +DEBUG ]: arrangeMonitors: DP-4 xwayland [0, 0] +DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads +DEBUG ]: [CXDGOutputProtocol] updating all xdg_output heads +DEBUG from aquamarine ]: drm: Cursor buffer imported into KMS with id 170 +ERR ]: CConfigWatcher: got an event for wd 1 which we don't have?! +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 1894 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: Hyprctl: new connection from pid 2089 +DEBUG ]: [CGammaControl] setGamma for DP-4 +DEBUG ]: [CGammaControl] setting to monitor DP-4 +DEBUG ]: Hyprctl: new connection from pid 6946 +DEBUG ]: Hyprctl: new connection from pid 6948 +DEBUG ]: Hyprctl: new connection from pid 6951 +DEBUG ]: Hyprctl: new connection from pid 6959 +DEBUG ]: Hyprctl: new connection from pid 6961 +DEBUG ]: Hyprctl: new connection from pid 6964 +DEBUG ]: Hyprctl: new connection from pid 6978 +DEBUG ]: Hyprctl: new connection from pid 6980 +DEBUG ]: Hyprctl: new connection from pid 6983 +DEBUG ]: Hyprctl: new connection from pid 7006 +DEBUG ]: Hyprctl: new connection from pid 7008 +DEBUG ]: Hyprctl: new connection from pid 7011 +DEBUG ]: Hyprctl: new connection from pid 7016 +DEBUG ]: Hyprctl: new connection from pid 7018 +DEBUG ]: Hyprctl: new connection from pid 7021 +DEBUG ]: [CGammaControl] setGamma for DP-4 +DEBUG ]: [CGammaControl] setting to monitor DP-4 +DEBUG ]: Hyprctl: new connection from pid 7033 +DEBUG ]: Hyprctl: new connection from pid 7035 +DEBUG ]: Hyprctl: new connection from pid 7038 +DEBUG ]: Keybind triggered, calling dispatcher (64, , 108, exec) +DEBUG ]: Executing layout-resize grow +DEBUG ]: Process Created with pid 7052 +DEBUG ]: Keybind triggered, calling dispatcher (64, , 108, exec) +DEBUG ]: Executing hyprlock +DEBUG ]: Process Created with pid 7053 +DEBUG ]: Hyprctl: new connection from pid 7055 +DEBUG ]: Hyprctl: new connection from pid 7057
\ No newline at end of file diff --git a/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt b/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt new file mode 100644 index 0000000..86b8cb0 --- /dev/null +++ b/working/hyprland-layoutmsg-crash/crash-report-2026-07-20-pid249443.txt @@ -0,0 +1,166 @@ +-------------------------------------------- + Hyprland Crash Report +-------------------------------------------- +Vaxry is going to be upset. + +Hyprland received signal 11(SEGV) +Version: a0136d8c04687bb36eb8a28eb9d1ff92aea99704 +Tag: v0.55.4 +Date: Thu Jun 11 17:10:04 2026 +Flags: + +System info: + System name: Linux + Node name: ratio + Release: 6.18.25-1-lts-strix + Version: #1 SMP PREEMPT_DYNAMIC Thu, 30 Apr 2026 05:02:04 +0000 + +GPU: + c3:00.0 Display controller [0380]: Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S Graphics / Radeon 8060S Graphics] [1002:1586] (rev c1) + + +os-release: + NAME="Arch Linux" + PRETTY_NAME="Arch Linux" + ID=arch + BUILD_ID=rolling + ANSI_COLOR="38;2;23;147;209" + HOME_URL="https://archlinux.org/" + DOCUMENTATION_URL="https://wiki.archlinux.org/" + SUPPORT_URL="https://bbs.archlinux.org/" + BUG_REPORT_URL="https://gitlab.archlinux.org/groups/archlinux/-/issues" + PRIVACY_POLICY_URL="https://terms.archlinux.org/docs/privacy-policy/" + LOGO=archlinux-logo + +Libraries: +Hyprgraphics: built against 0.5.1, system has 0.5.1 +Hyprutils: built against 0.13.1, system has 0.13.1 +Hyprcursor: built against 0.1.13, system has 0.1.13 +Hyprlang: built against 0.6.8, system has 0.6.8 +Aquamarine: built against 0.12.0, system has 0.12.1 + +Backtrace: + # | Hyprland(_Z12getBacktracev+0x73) [0x555c822eb7f3] + getBacktrace() + ??:? + #1 | Hyprland(_ZN13CrashReporter18createAndSaveCrashEi+0xc8c) [0x555c822328dc] + CrashReporter::createAndSaveCrash(int) + ??:? + #2 | Hyprland(+0x3bfade) [0x555c820f3ade] + void std::println<>(_IO_FILE*, std::basic_format_string<char>) + ??:? + #3 | /usr/lib/libc.so.6(+0x3e270) [0x7f58a9c3e270] + ?? + ??:0 + #4 | Hyprland(+0x1ce31e) [0x555c81f0231e] + std::__throw_bad_variant_access(unsigned int) + ??:? + #5 | Hyprland(_ZN6Layout10CAlgorithm9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0x85) [0x555c823859e5] + Layout::CAlgorithm::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #6 | Hyprland(_ZN6Layout6CSpace9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0x37) [0x555c823bf5c7] + Layout::CSpace::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #7 | Hyprland(_ZN6Layout14CLayoutManager9layoutMsgERKSt17basic_string_viewIcSt11char_traitsIcEE+0xcc) [0x555c8238193c] + Layout::CLayoutManager::layoutMsg(std::basic_string_view<char, std::char_traits<char> > const&) + ??:? + #8 | Hyprland(_ZN6Config7Actions13layoutMessageERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x6c) [0x555c8285cd5c] + Config::Actions::layoutMessage(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) + ??:? + #9 | Hyprland(+0xad8837) [0x555c8280c837] + Config::Legacy::CDispatcherTranslator::run(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) + ??:? + #1 | Hyprland(_ZNSt17_Function_handlerIF15SDispatchResultRKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEEEPS9_E9_M_invokeERKSt9_Any_dataS8_+0x25) [0x555c82819fb5] + std::_Function_handler<SDispatchResult (std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&), SDispatchResult (*)(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&)>::_M_invoke(std::_Any_data const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) + ??:? + #11 | Hyprland(_ZN6Config6Legacy21CDispatcherTranslator3runERKNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEES9_+0x256) [0x555c8280bc06] + Config::Legacy::CDispatcherTranslator::run(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > const&) + ??:? + #12 | Hyprland(+0x6b8419) [0x555c823ec419] + void Log::CLogger::log<int>(Hyprutils::CLI::eLogLevel, std::basic_format_string<char, std::type_identity<int>::type>, int&&) + ??:? + #13 | Hyprland(+0x4d67e0) [0x555c8220a7e0] + CHyprCtl::CHyprCtl() + ??:? + #14 | Hyprland(_ZNSt17_Function_handlerIFNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE20eHyprCtlOutputFormatS5_EPS7_E9_M_invokeERKSt9_Any_dataOS6_OS5_+0x75) [0x555c82226d95] + std::_Function_handler<std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >), std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> > (*)(eHyprCtlOutputFormat, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >)>::_M_invoke(std::_Any_data const&, eHyprCtlOutputFormat&&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&&) + ??:? + #15 | Hyprland(_ZN8CHyprCtl8getReplyENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE+0x3d6) [0x555c821ff826] + CHyprCtl::getReply(std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >) + ??:? + #16 | Hyprland(+0x4d7415) [0x555c8220b415] + CHyprCtl::CHyprCtl() + ??:? + #17 | /usr/lib/libwayland-server.so.0(wl_event_loop_dispatch+0x1d2) [0x7f58ab7a33d2] + ?? + ??:0 + #18 | /usr/lib/libwayland-server.so.0(wl_display_run+0x37) [0x7f58ab7a5567] + ?? + ??:0 + #19 | Hyprland(_ZN17CEventLoopManager9enterLoopEv+0x2c0) [0x555c82460620] + CEventLoopManager::enterLoop() + ??:? + #2 | Hyprland(main+0x1815) [0x555c81fac6d5] + main + ??:? + #21 | /usr/lib/libc.so.6(+0x27741) [0x7f58a9c27741] + ?? + ??:0 + #22 | /usr/lib/libc.so.6(__libc_start_main+0x89) [0x7f58a9c27879] + ?? + ??:0 + #23 | Hyprland(_start+0x25) [0x555c820dda75] + _start + ??:? + + +Log tail: +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: Hyprctl: new connection from pid 1591853 +DEBUG ]: Hyprctl: new connection from pid 1591855 +DEBUG ]: Hyprctl: new connection from pid 1591858 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: Keybind triggered, calling dispatcher (64, , 104, exec) +DEBUG ]: [executor] Executing layout-resize shrink +DEBUG ]: [executor] Process created with pid 1591862 +DEBUG ]: Hyprctl: new connection from pid 1591864 +DEBUG ]: Hyprctl: new connection from pid 1591866
\ No newline at end of file diff --git a/working/hyprland-layoutmsg-crash/issue-draft.md b/working/hyprland-layoutmsg-crash/issue-draft.md new file mode 100644 index 0000000..57e7462 --- /dev/null +++ b/working/hyprland-layoutmsg-crash/issue-draft.md @@ -0,0 +1,43 @@ +crash: uncaught std::bad_variant_access in Layout::CAlgorithm::layoutMsg on `layoutmsg mfact` (v0.55.4, also v0.54.1) + +### Hyprland Version + +v0.55.4, commit a0136d8c04687bb36eb8a28eb9d1ff92aea99704 (Arch package 0.55.4-1). The same crash hit v0.54.1 (commit 4b07770b) on 2026-03-07. Both crash reports are attached. + +### Bug or Regression? + +It's a bug: the same backtrace appears on v0.54.1 and v0.55.4. + +### Description + +Hyprland segfaults when a `hyprctl dispatch layoutmsg mfact -0.05` arrives. The backtrace shows an uncaught `std::bad_variant_access` in the layout message path: + +``` +std::__throw_bad_variant_access(unsigned int) +Layout::CAlgorithm::layoutMsg(std::basic_string_view<...> const&) +Layout::CSpace::layoutMsg(...) +Layout::CLayoutManager::layoutMsg(...) +Config::Actions::layoutMessage(...) +``` + +The active layout was master, and the identical mfact dispatch had worked seconds earlier in the same session. Related edge cases reply gracefully instead of crashing, so it looks like one mfact path is missing a variant guard: + +- `layoutmsg mfact` with no focused window replies "mfact -> no window" +- master-only messages sent while in monocle reply "Unknown monocle layoutmsg: swapwithmaster" + +### How to reproduce + +I don't have a deterministic reproducer. Both crashes came from a resize keybind that dispatches `layoutmsg mfact -0.05` (or `0.05`). What the session log shows in the minutes before today's crash: + +1. `general:layout` toggled between master and monocle several times during the session (long-lived session, 9 days). +2. Two windows closed shortly before the crash. The log shows "On closed window, new focused candidate is [Window nullptr]". +3. `togglefloating` dispatched twice. +4. The mfact keybind fired and Hyprland segfaulted. + +My guess from the outside: per-workspace layout state (CSpace) ends up holding a different variant alternative than the one the active layout name implies. The mfact handler then does an unchecked `std::get`. + +### Crash reports + +Attached: today's report (v0.55.4) and the 2026-03-07 report (v0.54.1) with the identical backtrace through `Layout::CAlgorithm::layoutMsg`. + +System: Arch Linux, kernel 6.18.25-lts. GPU: AMD Strix Halo (Radeon 8060S). No plugins loaded. diff --git a/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt b/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt new file mode 100644 index 0000000..af2aad6 --- /dev/null +++ b/working/hyprland-layoutmsg-crash/log-excerpts-2026-07-20-session.txt @@ -0,0 +1,63 @@ +Log excerpts from the crashed Hyprland session (instance _1783789239_, ran 2026-07-11 12:00 -> 2026-07-20 16:26:52 CDT). +Source: /run/user/1000/hypr/.../hyprland.log (tmpfs; extracted before reboot). Line numbers from the original 11,868,557-line file. + +== environment == +hyprland 0.55.4-1 + +== proof monocle is a registered layout (graceful unknown-msg handling) == +198330:DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact -0.05 -> Unknown monocle layoutmsg: mfact +254456:DEBUG ]: Hyprctl: dispatcher layoutmsg : swapwithmaster -> Unknown monocle layoutmsg: swapwithmaster +254458:DEBUG ]: Hyprctl: dispatcher layoutmsg : focusmaster master -> Unknown monocle layoutmsg: focusmaster +254976:DEBUG ]: Hyprctl: dispatcher layoutmsg : swapwithmaster -> Unknown monocle layoutmsg: swapwithmaster +254978:DEBUG ]: Hyprctl: dispatcher layoutmsg : focusmaster master -> Unknown monocle layoutmsg: focusmaster + +== guarded no-window mfact earlier in the session (line 10988160) == +DEBUG ]: Hyprctl: new connection from pid 3038571 +DEBUG ]: Hyprctl: new connection from pid 3038573 +DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05 -> no window +DEBUG ]: Hyprctl: new connection from pid 3038612 +DEBUG ]: Hyprctl: new connection from pid 3038614 + +== last monocle->master toggling (lines ~11599073, 11753369) == +9964283:DEBUG ]: Hyprctl: keyword general:layout : monocle +9968343:DEBUG ]: Hyprctl: keyword general:layout : master +11599073:DEBUG ]: Hyprctl: keyword general:layout : monocle +11753369:DEBUG ]: Hyprctl: keyword general:layout : master + +== last layout switch + immediately-working mfacts (lines 11753360-11753520) == +DEBUG ]: Keybind triggered, calling dispatcher (65, , 116, exec) +DEBUG ]: [executor] Executing hyprctl keyword general:layout master && hyprctl keyword master:orientation left +DEBUG ]: [executor] Process created with pid 1359114 +DEBUG ]: Hyprctl: keyword general:layout : master +DEBUG ]: Hyprctl: keyword master:orientation : left +DEBUG ]: [CWLCompositorResource] New wl_region with id 76 at 555c95821500 +DEBUG ]: [CWLCompositorResource] New wl_region with id 85 at 555c95c25820 +DEBUG ]: [executor] Executing layout-resize grow +DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05 +DEBUG ]: [executor] Executing layout-resize grow +DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05 +DEBUG ]: [executor] Executing layout-resize grow +DEBUG ]: Hyprctl: dispatcher layoutmsg : mfact 0.05 + +== pre-crash sequence: window closes, focus -> nullptr, togglefloating (lines ~11754750-11755050) == +DEBUG ]: [CGammaControl] setGamma for DP-4 +DEBUG ]: [CGammaControl] setting to monitor DP-4 +DEBUG ]: [CGammaControl] setGamma for DP-4 +DEBUG ]: [CGammaControl] setting to monitor DP-4 + +== final 15 lines on disk (ring-buffer tail with the fatal keybind is in the crash report) == +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95b15a20 requests geometry 0x0 1564x1254 +DEBUG ]: [CXDGSurfaceResource] xdg_surface 555c95dd70c0 requests geometry 0x0 1131x1257
\ No newline at end of file |
