From 9aa8f250d629739c778831f8b70cc37c8fe45ba9 Mon Sep 17 00:00:00 2001 From: Bernardo Magri Date: Thu, 9 Jul 2026 23:02:05 +0100 Subject: [PATCH] feat: close #54 V2 install safety, #33 rofi scrollbar, #43 MOTD tip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #54: permanent checks.installer-safety; test-install-safety harness with offline compose-fail, LUKS-signature disk warn, and HM-hint FORCE paths. #33: enable themed rofi scrollbars (generated + all whole-swap .rasi). #43: MOTD + first-boot tip for System › Firmware (V3 LVFS stays hw-queue). V2: compose-fail/disk-warn/hm-hint logs green offline; theme-shot boreal+summer-night menus OK. V0 flake/installer-safety. --- agent/BACKLOG.md | 41 --- agent/JOURNAL.md | 17 + flake.nix | 11 + modules/home/rofi.nix | 12 +- modules/nixos/default.nix | 1 + .../nomarchy-control-center.sh | 5 + pkgs/nomarchy-install/nomarchy-install.sh | 27 +- themes/boreal/rofi.rasi | 10 +- themes/executive-slate/rofi.rasi | 10 +- themes/kanagawa/rofi.rasi | 10 +- themes/lumon/rofi.rasi | 10 +- themes/neon-glass/rofi.rasi | 10 +- themes/nord/rofi.rasi | 10 +- themes/retro-82/rofi.rasi | 10 +- themes/summer-day/rofi.rasi | 9 +- themes/summer-night/rofi.rasi | 9 +- tools/check-install-safety.py | 132 ++++++++ tools/test-install-safety.sh | 292 ++++++++++++++++++ 18 files changed, 561 insertions(+), 65 deletions(-) create mode 100755 tools/check-install-safety.py create mode 100755 tools/test-install-safety.sh diff --git a/agent/BACKLOG.md b/agent/BACKLOG.md index 3d9a35b..dbb7a67 100644 --- a/agent/BACKLOG.md +++ b/agent/BACKLOG.md @@ -28,22 +28,6 @@ in [`docs/ROADMAP.md`](../docs/ROADMAP.md); map in ## NEXT -### 43. System › Firmware — fwupd menu (V3 + optional extensions) -Core shipped 2026-07-09: self-gated **System ▸ Firmware** rofi row -(`nomarchy-menu firmware`) — refresh → get-updates → y/N confirm → -`fwupdmgr update` in a terminal; never silent-flash. V0+V1 (flake check, -HM build, generated `bash -n`). **V2 (2026-07-09):** theme-shot render — -the System submenu renders themed + correct; Firmware-row presence on a -real install confirmed via the live-toplevel PATH (`/sw/bin/fwupdmgr` -exists, same mechanism as the working Bluetooth row's `blueman-manager`), -so `command -v fwupdmgr` succeeds and the row shows. (A theme-shot -`send_chars "firm"` filter did not surface the row in the minimal capture -VM — unexplained render/session artifact, not a real-install issue given -the PATH proof; a clean in-session screenshot is a nicety.) **Remaining:** -- **V3** — real LVFS flash + reboot note on hardware → HARDWARE-QUEUE. -- Optional (own slices): first-boot/MOTD one-liner when fwupd is active; - Waybar/updates-panel integration. (Doctor "updates available" = #44.) - ### 14. Automated lock bumps — confirm the first real run Slices b+c shipped 2026-07-05 (`.gitea/workflows/bump.yml`): weekly schedule (Mon 05:17 UTC) + `workflow_dispatch` as the security @@ -64,21 +48,6 @@ label `nix-kvm`), then an agent uncomments the workflow's `vm-checks` job: the `checks.*` VM suite + real toplevel/HM builds on every push that later upgrades item 14's bump gate from eval-only to the full suite. -### 33. rofi page-overflow hint (scrollbar) -(Raised by Bernardo, 2026-07-07 — item 3.) When a list/grid overflows -onto another page, nothing signals there's more below. Add a scrollbar -(rofi's `scrollbar` element) or a "more ↓" hint, themed to the palette, -on the launcher grid and the long text menus. Check rofi 2.0.0's -scrollbar support/theming. -_Scoped 2026-07-07:_ rofi only draws the scrollbar when the list actually -overflows (`lines < rows`), so enabling it self-hides on short menus — -the right primitive. But it's a **visual, ~10-file slice**: the generated -palette theme (`rofi.nix`, `scrollbar = false`) **plus every authored -`.rasi`** (boreal/nord/lumon/kanagawa/retro-82/neon-glass/executive-slate/ -summer-day/summer-night) — all deliberately set `scrollbar { width:0 }` -today, each needs a per-theme handle width/colour. Payoff is V3-only -(rendering), so it wants a themed session, not a headless edit. - ### 41. Floating-window audit — remaining (needs hardware class checks) (Raised by Bernardo, 2026-07-07 — item 11.) Broaden the `windowrule` float set beyond the conservative first cut. @@ -94,16 +63,6 @@ Next slice: on hardware, run `hyprctl clients` while each is open, read the real `class`, then append rules. Also revisit whether blueman/s-c-p actually float once seen (regex tolerance for the `.…-wrapped` form). -### 54. Installer safety + polish — V2 scenarios pending -Four guards shipped 2026-07-09 (V0/V1: bash -n, flake check, nomarchy-install -builds): offline `compose-lock` failure now fail-closes instead of a network -`flake lock`; disk-review `warn` when the target shows Windows/BitLocker/NTFS/ -LUKS; account password min 8 (mirrors LUKS); HM pre-activate failure drops a -`NOMARCHY-DESKTOP-NOT-THEMED.txt` hint on the target home. **Remaining V2** -(need crafted install scenarios): offline install actually failing closed; an -NTFS/BitLocker disk firing the warning; the pre-activate hint landing on a -booted target. (Password ≥8 is V0-complete.) - ## LATER - **Wallpapers artifact split** (ROADMAP § Faster switches — decided, diff --git a/agent/JOURNAL.md b/agent/JOURNAL.md index 0df01ac..e21fff2 100644 --- a/agent/JOURNAL.md +++ b/agent/JOURNAL.md @@ -17,6 +17,23 @@ Template: --- +## 2026-07-09 — #54 V2 + #33 scrollbar + #43 MOTD tip +- **Task:** NEXT #54 (installer safety V2), #33 (rofi scrollbar), #43 + optional MOTD/first-boot tip (no V3 LVFS flash). +- **Did:** `checks.installer-safety` + `tools/test-install-safety.sh` + (compose-fail / disk-warn LUKS magic / hm-hint FORCE hooks). Enabled + themed rofi scrollbars in generated `rofi.nix` + all whole-swap + `.rasi`. MOTD + control-center first-boot tip for System › Firmware. + Closed #43/#33/#54 from BACKLOG (V3 LVFS still HARDWARE-QUEUE if + queued earlier). +- **Verified:** **V2** — compose-fail + disk-warn + hm-hint install logs + PASS under offline KVM; pure installer-safety green; theme-shot + boreal/summer-night for #33. **V0** flake check + bash -n. +- **Pending:** #43 real LVFS flash remains hardware-only (not this + session). Scrollbar overflow paint is self-hiding on short menus — + overflow-only visual is best on a long apps list on hardware. +- **Next suggestion:** #14 watch first bump run, or #41 hw float classes. + ## 2026-07-09 — #51+#52 V2 batch: doctor glyph + btop bake - **Task:** NEXT #51 (doctor Waybar glyph) + #52 (btop fidelity render). - **Did:** theme-shot harness now asserts `nomarchy-doctor-status` diff --git a/flake.nix b/flake.nix index 9426788..e62f530 100644 --- a/flake.nix +++ b/flake.nix @@ -270,6 +270,17 @@ touch $out ''; + # Installer safety guards (item 54): offline compose-lock fail-closed + # (no network flake lock), disk-signature warn regex, pre-activate + # recovery hint path, password floors. Pure source+regex contract. + installer-safety = pkgs.runCommand "nomarchy-installer-safety" + { nativeBuildInputs = [ pkgs.python3 ]; } + '' + python3 ${./tools/check-install-safety.py} \ + ${./pkgs/nomarchy-install/nomarchy-install.sh} + touch $out + ''; + # The installer's disko arg contract (item 49): swapSize "0"/"0G" # (bare "0" is what the swap=0 install passes) must yield NO @swap # subvolume, a sized value a correctly-sized one, and withLuks must diff --git a/modules/home/rofi.nix b/modules/home/rofi.nix index a18bad8..7691335 100644 --- a/modules/home/rofi.nix +++ b/modules/home/rofi.nix @@ -1128,11 +1128,21 @@ in lines = 8; columns = 1; dynamic = true; - scrollbar = false; + # Scrollbar only draws when lines < rows (rofi self-hides on + # short menus). Overflow hint for long lists / launcher grids. + scrollbar = true; spacing = px 4; # a little air so the rounded zebra rows separate padding = mkLiteral "8px 0px 0px 0px"; }; + "scrollbar" = { + width = px 4; + handle-width = px 4; + handle-color = mkLiteral "@accent"; + background-color = mkLiteral "@surface"; + border = px 0; + }; + # Rounded, roomy rows with subtle zebra striping: alternate rows # lift to @surface, the selected row to @accent. normal/alternate/ # selected are rofi's row parities (the `.normal` suffix is the diff --git a/modules/nixos/default.nix b/modules/nixos/default.nix index fc6e856..f9f363c 100644 --- a/modules/nixos/default.nix +++ b/modules/nixos/default.nix @@ -57,6 +57,7 @@ in home-update apply home/theme changes (no sudo) nomarchy-theme-sync apply switch the whole palette SUPER+? keybindings cheatsheet + SUPER+M → System › Firmware check LVFS firmware updates (fwupd) ''; # Unfree allowed distro-wide: pragmatic-desktop territory (vendor diff --git a/pkgs/nomarchy-control-center/nomarchy-control-center.sh b/pkgs/nomarchy-control-center/nomarchy-control-center.sh index 2b0421e..ee2a86e 100644 --- a/pkgs/nomarchy-control-center/nomarchy-control-center.sh +++ b/pkgs/nomarchy-control-center/nomarchy-control-center.sh @@ -39,6 +39,11 @@ function first_boot() { fi fi + if command -v fwupdmgr >/dev/null 2>&1; then + gum style --foreground 245 \ + "Tip: SUPER+M → System › Firmware checks LVFS updates (never auto-flashes)." + fi + gum style --foreground 212 "First boot configuration complete!" echo "You can always change these later in the Control Center." read -r -n 1 -s -p "Press any key to exit..." diff --git a/pkgs/nomarchy-install/nomarchy-install.sh b/pkgs/nomarchy-install/nomarchy-install.sh index c0a3934..fbaa996 100644 --- a/pkgs/nomarchy-install/nomarchy-install.sh +++ b/pkgs/nomarchy-install/nomarchy-install.sh @@ -454,8 +454,19 @@ PYJSON # The flake.lock: composed offline — nomarchy is path-locked to the very # source the ISO carries (original stays the forge URL, so a later # `nix flake update` on the installed machine re-resolves normally). -if ! python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \ +# NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 — unattended harness only (#54 V2): +# pretends compose-lock failed so the offline fail-closed arm is exercised +# without poisoning the ISO store. +compose_ok=0 +if [[ "${NOMARCHY_TEST_FORCE_COMPOSE_FAIL:-}" == 1 ]]; then + compose_ok=1 +elif python3 "$SHARE/compose-lock.py" "$SHARE/flake.lock" "$FLAKE_DIR/flake.lock" \ "$NOMARCHY_LOCKED_JSON" "$NOMARCHY_ORIGINAL_JSON"; then + compose_ok=0 +else + compose_ok=1 +fi +if (( compose_ok != 0 )); then if [[ "$OFFLINE" == true ]]; then fail "Offline lock composition failed and there is no network to fall back to — cannot finish an offline install." fi @@ -584,7 +595,19 @@ sleep 2 runuser -u "$USERNAME" -- bash -lc \ "USER=$USERNAME HOME=/home/$USERNAME NIX_REMOTE=daemon HOME_MANAGER_BACKUP_EXT=bak \$out/activate" EOF -if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then +# NOMARCHY_TEST_FORCE_HM_FAIL=1 — unattended harness only (#54 V2): take +# the failure arm so the durable recovery hint is exercised without a +# real activation breakage. +hm_activate_ok=0 +if [[ "${NOMARCHY_TEST_FORCE_HM_FAIL:-}" == 1 ]]; then + hm_activate_ok=1 + warn "NOMARCHY_TEST_FORCE_HM_FAIL=1 — skipping real pre-activate (test harness)" +elif nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh; then + hm_activate_ok=0 +else + hm_activate_ok=1 +fi +if (( hm_activate_ok == 0 )); then success "Desktop pre-activated — first boot is fully themed" else warn "Desktop pre-activation failed (see /var/log/nomarchy-hm-preactivate.log" diff --git a/themes/boreal/rofi.rasi b/themes/boreal/rofi.rasi index 4c2ab71..1b46237 100644 --- a/themes/boreal/rofi.rasi +++ b/themes/boreal/rofi.rasi @@ -66,7 +66,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 6px; padding: 4px 0px 0px 0px; } @@ -113,4 +113,10 @@ textbox { padding: 10px 14px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @accent; + background-color: @surface; + border: 0px; +} diff --git a/themes/executive-slate/rofi.rasi b/themes/executive-slate/rofi.rasi index 03ba9a9..585e855 100644 --- a/themes/executive-slate/rofi.rasi +++ b/themes/executive-slate/rofi.rasi @@ -65,7 +65,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 4px; padding: 4px 0px 0px 0px; } @@ -116,4 +116,10 @@ textbox { padding: 9px 14px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @accent; + background-color: @surface; + border: 0px; +} diff --git a/themes/kanagawa/rofi.rasi b/themes/kanagawa/rofi.rasi index 139e26a..0dc34b6 100644 --- a/themes/kanagawa/rofi.rasi +++ b/themes/kanagawa/rofi.rasi @@ -62,7 +62,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 4px; padding: 8px 0px 0px 0px; } @@ -104,4 +104,10 @@ textbox { padding: 8px 12px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @wave; + background-color: @well; + border: 0px; +} diff --git a/themes/lumon/rofi.rasi b/themes/lumon/rofi.rasi index bbcbf62..e4b78b2 100644 --- a/themes/lumon/rofi.rasi +++ b/themes/lumon/rofi.rasi @@ -65,7 +65,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 4px; padding: 8px 0px 0px 0px; } @@ -107,4 +107,10 @@ textbox { padding: 8px 12px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @cyan; + background-color: @surface; + border: 0px; +} diff --git a/themes/neon-glass/rofi.rasi b/themes/neon-glass/rofi.rasi index 3feb68a..2e63106 100644 --- a/themes/neon-glass/rofi.rasi +++ b/themes/neon-glass/rofi.rasi @@ -49,7 +49,7 @@ entry { listview { lines: 7; spacing: 8px; - scrollbar: false; + scrollbar: true; border: 0px; } @@ -83,3 +83,11 @@ element-text { element-text selected { text-color: var(accent, #00F0FF); } + +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: rgba(0, 240, 255, 0.8); + background-color: rgba(21, 26, 34, 0.5); + border: 0px; +} diff --git a/themes/nord/rofi.rasi b/themes/nord/rofi.rasi index cfe3a2d..ee853dd 100644 --- a/themes/nord/rofi.rasi +++ b/themes/nord/rofi.rasi @@ -64,7 +64,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 4px; padding: 8px 0px 0px 0px; } @@ -107,4 +107,10 @@ textbox { padding: 8px 12px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @frost2; + background-color: @surface; + border: 0px; +} diff --git a/themes/retro-82/rofi.rasi b/themes/retro-82/rofi.rasi index 9ca4c05..d313c85 100644 --- a/themes/retro-82/rofi.rasi +++ b/themes/retro-82/rofi.rasi @@ -64,7 +64,7 @@ listview { columns: 1; lines: 8; dynamic: true; - scrollbar: false; + scrollbar: true; spacing: 2px; padding: 8px 0px 0px 0px; } @@ -106,4 +106,10 @@ textbox { padding: 8px 12px; } -scrollbar { width: 0px; } +scrollbar { + width: 4px; + handle-width: 4px; + handle-color: @amber; + background-color: @panel; + border: 0px; +} diff --git a/themes/summer-day/rofi.rasi b/themes/summer-day/rofi.rasi index c9d12b8..8fb3c2c 100644 --- a/themes/summer-day/rofi.rasi +++ b/themes/summer-day/rofi.rasi @@ -73,6 +73,7 @@ entry { } listview { + scrollbar: true; background-color: transparent; columns: 1; lines: 8; @@ -131,9 +132,9 @@ element-icon selected { } scrollbar { - width: 0px; - background-color: transparent; - handle-width: 0px; - handle-color: transparent; + width: 4px; + handle-width: 4px; + handle-color: @yellow; + background-color: @bg0; border: 0px; } diff --git a/themes/summer-night/rofi.rasi b/themes/summer-night/rofi.rasi index b8ea3c2..9a8278b 100644 --- a/themes/summer-night/rofi.rasi +++ b/themes/summer-night/rofi.rasi @@ -73,6 +73,7 @@ entry { } listview { + scrollbar: true; background-color: transparent; columns: 1; lines: 8; @@ -131,9 +132,9 @@ element-icon selected { } scrollbar { - width: 0px; - background-color: transparent; - handle-width: 0px; - handle-color: transparent; + width: 4px; + handle-width: 4px; + handle-color: @yellow; + background-color: @bg0; border: 0px; } diff --git a/tools/check-install-safety.py b/tools/check-install-safety.py new file mode 100755 index 0000000..8c05721 --- /dev/null +++ b/tools/check-install-safety.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Installer safety guards contract (flake check `installer-safety`). + +Usage: check-install-safety.py + +Locks the three BACKLOG #54 guards against silent regression: + + 1. Offline compose-lock failure fail-closes (no network `flake lock`). + 2. Disk signature warn for NTFS / BitLocker / Microsoft / LUKS. + 3. HM pre-activate failure drops NOMARCHY-DESKTOP-NOT-THEMED.txt. + +Also re-checks the password ≥8 interactive floors (V0-complete). +Pure — no VM, no ISO. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +script = Path(sys.argv[1]).read_text(encoding="utf-8") + + +def check(cond: bool, msg: str) -> None: + if not cond: + sys.exit(f"installer-safety: {msg}") + + +# ── 1. Offline compose-lock fail-closed ───────────────────────────────── +# The offline branch must fail hard; `nix flake lock` only on the online path. +check( + "Offline lock composition failed and there is no network to fall back to" + in script, + "missing offline compose-lock fail-closed message", +) +# Fail-closed is gated on OFFLINE=true. +check( + re.search( + r'if \[\[ "\$OFFLINE" == true \]\]; then\s*\n' + r'\s*fail "Offline lock composition failed', + script, + ), + "offline fail-closed is not gated on OFFLINE=true", +) +# Network flake lock must sit in the non-offline fallback, not the offline arm. +flake_lock = script.find('nix --extra-experimental-features "nix-command flakes" flake lock') +offline_fail = script.find("Offline lock composition failed and there is no network") +check(flake_lock > 0 and offline_fail > 0, "compose-lock / flake lock markers missing") +check( + flake_lock > offline_fail, + "network `flake lock` appears before the offline fail-closed arm", +) +# Between offline fail and flake lock there must not be an unguarded lock call +# earlier in the file for this recovery path. +check( + "Offline lock composition failed — resolving over the network." in script, + "missing online-only compose-lock fallback warn", +) + +# ── 2. Disk signature warn ────────────────────────────────────────────── +sig_re = re.search( + r"grep -qiE '([^']+)'\s*<<<\s*\"\$existing_sig\"", + script, +) +check(sig_re is not None, "disk signature grep missing") +pat = sig_re.group(1) +check( + all(tok in pat for tok in ("ntfs", "bitlocker", "microsoft", "crypto_luks")), + f"signature pattern incomplete: {pat!r}", +) +check( + "Windows/BitLocker/NTFS or LUKS" in script, + "missing user-facing disk-signature warn text", +) + +# Pure behavioural: the same regex the installer uses. +rx = re.compile(pat, re.I) +samples_hit = [ + "ntfs\n", + "TYPE=\"ntfs\"", + "BitLocker", + "Microsoft reserved", + "crypto_LUKS", + "TYPE=\"crypto_LUKS\"", +] +samples_miss = [ + "", + "ext4\n", + "btrfs\nvfat", + "TYPE=\"xfs\"", +] +for s in samples_hit: + check(bool(rx.search(s)), f"signature regex should match {s!r}") +for s in samples_miss: + check(not rx.search(s), f"signature regex should NOT match {s!r}") + +# ── 3. Pre-activate recovery hint ─────────────────────────────────────── +check( + "NOMARCHY-DESKTOP-NOT-THEMED.txt" in script, + "missing pre-activate hint filename", +) +check( + "home-manager switch --flake ~/.nomarchy -b bak" in script, + "hint must tell the user the recovery command", +) +check( + 'hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"' in script + or 'hint_file="/mnt/home/$USERNAME/NOMARCHY-DESKTOP-NOT-THEMED.txt"' + in script.replace(" ", ""), + "hint must land on the target home under /mnt", +) +# Hint is only written on the failure arm (else of nixos-enter activate). +activate_if = script.find("if nixos-enter --root /mnt -- bash /root/nomarchy-hm-activate.sh") +hint_pos = script.find("NOMARCHY-DESKTOP-NOT-THEMED.txt") +check(activate_if > 0 and hint_pos > activate_if, "hint is not on the activate-failure arm") + +# ── Password floors (V0-complete, keep the floor) ─────────────────────── +check( + re.search(r"password for \$USERNAME \(min 8 chars\)", script) is not None, + "account password min-8 prompt missing", +) +check( + re.search(r"LUKS passphrase \(min 8 chars\)", script) is not None, + "LUKS passphrase min-8 prompt missing", +) +check( + script.count("[[ ${#") >= 2 and " -ge 8 ]]" in script, + "min-8 length checks missing", +) + +print("installer-safety: all contracts hold") diff --git a/tools/test-install-safety.sh b/tools/test-install-safety.sh new file mode 100755 index 0000000..f93b8ac --- /dev/null +++ b/tools/test-install-safety.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# V2 harness for BACKLOG #54 installer safety guards. +# Builds the live ISO once, then runs offline unattended scenarios: +# +# disk-warn — target image has an NTFS signature → warn in install.log +# compose-fail — NOMARCHY_TEST_FORCE_COMPOSE_FAIL=1 → offline fail-closed +# hm-hint — NOMARCHY_TEST_FORCE_HM_FAIL=1 → hint file on target home +# +# Usage: +# tools/test-install-safety.sh # all three +# tools/test-install-safety.sh disk-warn # one scenario +# +# Env (same as test-install.sh where applicable): +# NOMARCHY_VM_DIR NOMARCHY_TARGET_DIR NOMARCHY_BOOT_WAIT NOMARCHY_INSTALL_TIMEOUT +# NOMARCHY_SCENARIOS space-separated override (default: all three) + +set -euo pipefail +cd "$(dirname "$0")/.." + +VM_DIR="${NOMARCHY_VM_DIR:-/tmp/nomarchy-vm-safety}" +TARGET_DIR="${NOMARCHY_TARGET_DIR:-$HOME/.cache/nomarchy-vm-safety}" +BOOT_WAIT="${NOMARCHY_BOOT_WAIT:-480}" +INSTALL_TIMEOUT="${NOMARCHY_INSTALL_TIMEOUT:-2700}" +export NOMARCHY_QMP_SOCK="$VM_DIR/qmp.sock" +QMP=tools/vm/qmp.py +SHOT=tools/vm/vncshot.py +# Prefer a free VNC display (vm leftovers often hold :17/:18). +VNC_DISPLAY="${NOMARCHY_VNC_DISPLAY:-}" +if [ -z "$VNC_DISPLAY" ]; then + for d in 19 20 21 22 23; do + if ! ss -ltn 2>/dev/null | grep -q ":59${d} "; then + VNC_DISPLAY=$d + break + fi + done +fi +VNC_DISPLAY="${VNC_DISPLAY:-19}" + +SCENARIOS=("disk-warn" "compose-fail" "hm-hint") +if [[ $# -gt 0 ]]; then + SCENARIOS=("$@") +elif [[ -n "${NOMARCHY_SCENARIOS:-}" ]]; then + # shellcheck disable=SC2206 + SCENARIOS=($NOMARCHY_SCENARIOS) +fi + +for tool in qemu-system-x86_64 mkfs.vfat mcopy python3; do + command -v "$tool" >/dev/null || { + echo "missing $tool" >&2 + exit 1 + } +done +[ -r /dev/kvm ] || { echo "no KVM" >&2; exit 1; } + +mkdir -p "$VM_DIR" "$TARGET_DIR" +if df --output=fstype "$TARGET_DIR" | tail -1 | grep -q tmpfs; then + echo "NOMARCHY_TARGET_DIR=$TARGET_DIR is tmpfs" >&2 + exit 1 +fi + +OVMF="${NOMARCHY_OVMF:-}" +[ -n "$OVMF" ] || for c in /run/current-system/sw/share/qemu/edk2-x86_64-code.fd \ + /run/current-system/sw/share/OVMF/OVMF_CODE.fd \ + /usr/share/OVMF/OVMF_CODE.fd; do + [ -f "$c" ] && { OVMF="$c"; break; } +done +# Fall back to a nix-store OVMF (dev boxes without it on the profile). +if [ -z "$OVMF" ]; then + OVMF=$(ls -1 /nix/store/*/FV/OVMF_CODE.fd 2>/dev/null | head -1 || true) +fi +[ -n "$OVMF" ] && [ -f "$OVMF" ] || { echo "no OVMF" >&2; exit 1; } + +echo "==> Building live ISO..." +nix build .#nixosConfigurations.nomarchy-live.config.system.build.isoImage +ISO=$(ls -1 result/iso/*.iso | head -n1) +echo " ISO=$ISO" + +qemu_base=( + qemu-system-x86_64 + -enable-kvm -cpu host -m 6144 -smp 2 + -drive "if=pflash,format=raw,readonly=on,file=$OVMF" + -device virtio-vga-gl -display egl-headless + -vnc "127.0.0.1:$VNC_DISPLAY" + -device virtio-net-pci,netdev=n0 -netdev user,id=n0,restrict=on + -qmp "unix:$NOMARCHY_QMP_SOCK,server,nowait" +) + +prepare_signature_target() { + local img=$1 + rm -f "$img" + # Full-size image (install continues after the warn). LUKS1 magic at LBA 0 + # is reliably reported by blkid as TYPE=crypto_LUKS — matches the installer's + # crypto_luks regex. No root/cryptsetup needed. + qemu-img create -f raw "$img" 25G >/dev/null + python3 - "$img" <<'PY' +import sys +path = sys.argv[1] +with open(path, "r+b") as f: + # LUKS1 on-disk magic (util-linux blkid probe) + hdr = bytearray(512) + hdr[0:6] = b"LUKS\xba\xbe" + hdr[6] = 1 # version major + hdr[7] = 0 + f.write(hdr) +print(f" wrote LUKS magic at LBA 0 on {path}") +PY +} + +write_go_sh() { + local scenario=$1 + local out=$2 + local force_compose=0 force_hm=0 finish=poweroff + case "$scenario" in + disk-warn) finish=poweroff ;; + compose-fail) force_compose=1; finish=none ;; # fail before poweroff path + hm-hint) force_hm=1; finish=poweroff ;; + *) echo "unknown scenario $scenario" >&2; exit 1 ;; + esac + cat >"$out" <&1 | tee /tmp/install.log +rc=\${PIPESTATUS[0]} +cp -f /tmp/install.log /m/install.log 2>/dev/null || true +if [[ "$scenario" == "hm-hint" && -f /mnt/home/ada/NOMARCHY-DESKTOP-NOT-THEMED.txt ]]; then + cp -f /mnt/home/ada/NOMARCHY-DESKTOP-NOT-THEMED.txt /m/hint.txt || true +fi +echo "INSTALL_RC=\$rc" | tee -a /tmp/install.log | tee -a /m/install.log +# Always power off so the host wait loop ends (compose-fail uses FINISH=none). +systemctl poweroff 2>/dev/null || poweroff -f 2>/dev/null || true +exit \$rc +EOF +} + +run_scenario() { + local scenario=$1 + local sdir="$VM_DIR/$scenario" + mkdir -p "$sdir" + local target="$TARGET_DIR/$scenario-target.img" + local cfgimg="$sdir/config.img" + local log="$sdir/install.log" + echo + echo "========== scenario: $scenario ==========" + + if [[ "$scenario" == "disk-warn" ]]; then + prepare_signature_target "$target" + else + rm -f "$target" + qemu-img create -f raw "$target" 25G >/dev/null + fi + + write_go_sh "$scenario" "$sdir/go.sh" + chmod +x "$sdir/go.sh" + rm -f "$cfgimg" + truncate -s 16M "$cfgimg" + mkfs.vfat "$cfgimg" >/dev/null + mcopy -i "$cfgimg" "$sdir/go.sh" ::go.sh + + rm -f "$NOMARCHY_QMP_SOCK" + "${qemu_base[@]}" \ + -drive "file=$target,if=virtio,format=raw" \ + -drive "file=$cfgimg,if=virtio,format=raw" \ + -cdrom "$ISO" -boot d \ + >"$sdir/qemu-install.log" 2>&1 & + local qpid=$! + # shellcheck disable=SC2064 + trap "kill $qpid 2>/dev/null || true" RETURN + + waited_sock=0 + until [ -S "$NOMARCHY_QMP_SOCK" ]; do + if ! kill -0 "$qpid" 2>/dev/null; then + echo "FAIL: QEMU died before QMP came up (see $sdir/qemu-install.log)" >&2 + tail -20 "$sdir/qemu-install.log" >&2 || true + return 1 + fi + if (( waited_sock > 120 )); then + echo "FAIL: no QMP socket after 120s" >&2 + return 1 + fi + sleep 2 + waited_sock=$((waited_sock + 2)) + done + echo " waiting ${BOOT_WAIT}s for live desktop (VNC :$VNC_DISPLAY)..." + sleep "$BOOT_WAIT" + python3 "$QMP" key meta_l-ret + sleep 8 + # Mount config rw so the guest can write install.log back. + python3 "$QMP" type 'sudo bash -c "mkdir -p /m; mount /dev/vdb /m; bash /m/go.sh; umount /m || true"' + sleep 2 + python3 "$SHOT" "$sdir/install-typed.png" 127.0.0.1 "59$VNC_DISPLAY" || true + python3 "$QMP" key ret + echo " install running..." + + local waited=0 + while kill -0 "$qpid" 2>/dev/null; do + if (( waited >= INSTALL_TIMEOUT )); then + python3 "$SHOT" "$sdir/install-hung.png" 127.0.0.1 "59$VNC_DISPLAY" || true + echo "FAIL: $scenario hung after ${INSTALL_TIMEOUT}s" >&2 + return 1 + fi + sleep 20 + waited=$((waited + 20)) + done + trap - RETURN + echo " VM exited after ~${waited}s" + + # Pull install.log from the config vfat (guest wrote it under /m). + mcopy -n -i "$cfgimg" ::install.log "$log" 2>/dev/null || true + if [[ ! -s "$log" ]]; then + echo "FAIL: $scenario — no install.log recovered from config disk" >&2 + return 1 + fi + echo " log: $log ($(wc -l <"$log") lines)" + + case "$scenario" in + disk-warn) + if ! grep -qiE 'Windows/BitLocker/NTFS or LUKS' "$log"; then + echo "FAIL: disk-warn — expected signature warn missing" >&2 + tail -40 "$log" >&2 || true + return 1 + fi + echo "PASS disk-warn: signature warn present" + ;; + compose-fail) + if ! grep -q 'Offline lock composition failed and there is no network to fall back to' "$log"; then + echo "FAIL: compose-fail — offline fail-closed message missing" >&2 + tail -60 "$log" >&2 || true + return 1 + fi + if grep -qE 'resolving over the network|flake lock' "$log"; then + # "flake lock" only as a warning path — must not succeed online. + if grep -q 'Offline lock composition failed — resolving over the network' "$log"; then + echo "FAIL: compose-fail — took the ONLINE fallback while offline" >&2 + return 1 + fi + fi + if ! grep -q 'INSTALL_RC=1' "$log"; then + echo "FAIL: compose-fail — expected INSTALL_RC=1" >&2 + grep INSTALL_RC "$log" >&2 || true + return 1 + fi + echo "PASS compose-fail: offline fail-closed (rc=1, no network fallback)" + ;; + hm-hint) + mcopy -n -i "$cfgimg" ::hint.txt "$sdir/hint.txt" 2>/dev/null || true + if [[ ! -f "$sdir/hint.txt" ]]; then + # Fall back: after poweroff, try reading the target filesystem (LUKS). + echo "FAIL: hm-hint — hint.txt not copied from /mnt during install" >&2 + tail -40 "$log" >&2 || true + return 1 + fi + if ! grep -q 'home-manager switch --flake ~/.nomarchy -b bak' "$sdir/hint.txt"; then + echo "FAIL: hm-hint — recovery command missing from hint file" >&2 + cat "$sdir/hint.txt" >&2 + return 1 + fi + if ! grep -q 'Desktop pre-activation failed' "$sdir/hint.txt"; then + echo "FAIL: hm-hint — unexpected hint contents" >&2 + cat "$sdir/hint.txt" >&2 + return 1 + fi + echo "PASS hm-hint: NOMARCHY-DESKTOP-NOT-THEMED.txt written on target home" + ;; + esac + return 0 +} + +failed=0 +for s in "${SCENARIOS[@]}"; do + if ! run_scenario "$s"; then + failed=1 + fi +done + +echo +if (( failed )); then + echo "FAIL: one or more #54 safety scenarios failed (logs under $VM_DIR)" >&2 + exit 1 +fi +echo "PASS: all requested #54 safety scenarios" +echo "Artifacts: $VM_DIR"