Files
Nomarchy/tools/check-install-safety.py
Bernardo Magri 9d0abe5422
All checks were successful
Check / eval (push) Successful in 3m18s
fix(install): normalize empty keyboard variants
Treat the picker-only (none) row as an empty XKB variant at the shared input boundary and again in the template patcher. Guard the real downstream output and console keymap, and carry the sentinel through the offline installer VM.

Verified: V0 full flake evaluation plus shell/Python/Nix/diff checks; V1 installer-keyboard, installer-safety, and template-SoT builds; V2 full KVM offline LUKS+swap install and themed first boot. No V3 required.
2026-07-13 15:01:51 +01:00

260 lines
9.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Installer safety guards contract (flake check `installer-safety`).
Usage: check-install-safety.py <nomarchy-install.sh> [<patch-template.py> <template>]
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) and, when
given the patcher + template, the BACKLOG #91 no-keyboard-variant path.
Pure — no VM, no ISO.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
if len(sys.argv) not in (2, 4):
sys.exit(
f"usage: {sys.argv[0]} <nomarchy-install.sh> "
"[<patch-template.py> <template>]"
)
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)",
)
# ── 5. Keyboard no-variant normalization (BACKLOG #91) ────────────────
if len(sys.argv) == 4:
# The picker sentinel must be normalized after both input paths converge,
# not only inside the interactive gum branch.
normalize_re = re.compile(
r'if \[\[ "\$KB_VARIANT" == "\(none\)" \]\]; then\s+'
r'KB_VARIANT=""\s+fi'
)
normalizations = list(normalize_re.finditer(script))
check(
len(normalizations) == 1,
"expected one set -e-safe keyboard `(none)` normalization",
)
normalize_at = normalizations[0].start() if normalizations else -1
input_branch_end = script.find(
'[[ "$USERNAME" =~ ^[a-z_][a-z0-9_-]*$ ]]', normalize_at
)
unattended_at = script.find('KB_VARIANT="${NOMARCHY_KB_VARIANT:-}"')
check(
unattended_at < normalize_at < input_branch_end,
"keyboard variant normalization is not on the shared input boundary",
)
values = {
"hostname": "keyboard-test",
"username": "nomarchy",
"timezone": "UTC",
"locale": "en_US.UTF-8",
"keyboardLayout": "us",
# This is the no-variant row as gum presents it. Even if a caller
# reaches the patcher before shell normalization, it must stay data,
# never become an XKB include name.
"keyboardVariant": "(none)",
"hashedPassword": "$6$test$hash",
"autoLogin": False,
"laptop": False,
"thermald": False,
"hardwareProfiles": [],
"hardware": {},
"resumeOffset": None,
"rootUuid": None,
}
patcher = Path(sys.argv[2])
template = Path(sys.argv[3])
with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-") as tmp:
generated = Path(tmp) / "flake"
shutil.copytree(template, generated)
# Nix-store templates are read-only; the real installer copy is
# writable, so reproduce that property in the build sandbox.
for path in generated.iterdir():
if path.is_file():
path.chmod(path.stat().st_mode | 0o200)
result = subprocess.run(
[sys.executable, str(patcher), str(generated)],
input=json.dumps(values),
text=True,
capture_output=True,
check=False,
)
check(
result.returncode == 0,
f"keyboard fixture patch failed: {result.stderr.strip()}",
)
home = (generated / "home.nix").read_text(encoding="utf-8")
system = (generated / "system.nix").read_text(encoding="utf-8")
expected = (
(home, 'nomarchy.keyboard.layout = "us";'),
(home, 'nomarchy.keyboard.variant = "";'),
(system, 'services.xserver.xkb.layout = "us";'),
(system, 'services.xserver.xkb.variant = "";'),
)
for text, line in expected:
check(line in text, f"generated configuration missing {line!r}")
check(
"(none)" not in home and "(none)" not in system,
"keyboard picker sentinel leaked into generated Nix",
)
print("installer-safety: all contracts hold")