#!/usr/bin/env python3 """Installer safety guards contract (flake check `installer-safety`). Usage: check-install-safety.py Locks the BACKLOG #54 safety guards (+ #61 review Source line) 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. 4. Review panel Source line tracks the same OFFLINE probe as the install path. 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", ) # ── 4. Review Source line tracks OFFLINE (BACKLOG #61) ────────────────── check( "pinned into the ISO, no network needed" in script, "missing offline Review Source wording", ) check( "may use network binary caches" in script, "missing online Review Source wording", ) check( re.search( r'if \[\[ "\$OFFLINE" == true \]\]; then\s*\n' r'\s*SOURCE_NET="pinned into the ISO, no network needed"', script, ), "offline Source wording is not gated on OFFLINE=true via SOURCE_NET", ) check( 'SOURCE_NET="pinned into the ISO; may use network binary caches"' in script, "online SOURCE_NET assignment missing", ) # Source gum line must expand $SOURCE_NET (not hardcode offline-only text). # The line embeds nested quotes, so match from Source: through SOURCE_NET. check( re.search(r'"Source:\s+nomarchy.*\$SOURCE_NET"', script, re.DOTALL) is not None, "Source gum line missing or does not expand $SOURCE_NET", ) # The only "no network needed" occurrence must be the offline SOURCE_NET assign. check( script.count("no network needed") == 1, "offline Source phrase should appear exactly once (SOURCE_NET assign)", ) print("installer-safety: all contracts hold")