Files
Nomarchy/tools/check-install-safety.py
Bernardo Magri d09c11d872
All checks were successful
Check / eval (push) Successful in 3m17s
fix(install): constrain keyboard selection to XKB catalog
Make keyboard typing filter-only with Gum strict mode, validate interactive and unattended choices against the pinned XKB catalog before any disk mutation, and show resolved human-readable choices in review.

Verified: V1 flake eval, focused installer builds, shell/Python/diff checks; current-ISO KVM boot proved the packaged new flow while the pre-destructive interaction pass continued.
2026-07-13 17:07:08 +01:00

350 lines
13 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), the
BACKLOG #92 catalog-only keyboard boundary, 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 os
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",
)
# ── 6. Catalog-only keyboard input (BACKLOG #92) ──────────────────
# gum input is search-only: selections are parsed from rows generated by
# the installed layout/variant catalogs, then both interactive and
# unattended paths meet the same exact-membership validator.
check(
"keyboard_pick=$(keyboard_layout_choices" in script
and 'gum filter --strict' in script
and 'KB_LAYOUT="${keyboard_pick%%$\'\\t\'*}"' in script,
"layout picker is not strict/catalog-constrained",
)
check(
'keyboard_variant_choices "$KB_LAYOUT"' in script
and script.count("gum filter --strict") == 2
and 'KB_VARIANT="${keyboard_pick%%$\'\\t\'*}"' in script,
"variant picker is not strict/catalog-constrained",
)
check(
not re.search(r"KB_(?:LAYOUT|VARIANT)=\$\(gum input", script),
"keyboard choice must not come from a free-form gum input",
)
validation_at = script.find(
'keyboard_error=$(validate_keyboard_choice "$KB_LAYOUT" "$KB_VARIANT"'
)
prewipe_at = script.find('prewipe "$TARGET_DISK"')
patch_at = script.find('python3 "$SHARE/patch-template.py"')
check(
normalize_at < validation_at < prewipe_at < patch_at,
"shared keyboard validation must fail before disk changes/generated Nix",
)
check(
'"Keyboard: $KEYBOARD_SUMMARY"' in script
and "standard keys (no special variant)" in script,
"review does not show a plain-language resolved keyboard choice",
)
# Exercise the real shell validator with a tiny deterministic XKB rules
# catalog. Exact boundaries matter: a prefix or a made-up unattended
# value must fail here, not become an XKB/Nix build error later.
with tempfile.TemporaryDirectory(prefix="nomarchy-keyboard-catalog-") as tmp:
rules = Path(tmp) / "base.lst"
rules.write_text(
"""! layout
us English (US)
gb English (UK)
! variant
intl us: English (US, intl., with dead keys)
dvorak us: English (Dvorak)
extd gb: English (UK, extended, with Win keys)
! option
""",
encoding="utf-8",
)
env = os.environ.copy()
env["NOMARCHY_XKB_RULES"] = str(rules)
def keyboard_validate(layout: str, variant: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["bash", str(sys.argv[1]), "--validate-keyboard", layout, variant],
text=True,
capture_output=True,
check=False,
env=env,
)
for layout, variant in (("us", ""), ("us", "intl"), ("gb", "extd")):
result = keyboard_validate(layout, variant)
check(
result.returncode == 0,
f"valid keyboard choice {layout!r}/{variant!r} rejected: {result.stderr.strip()}",
)
invalid = (
("u", "", "Unknown keyboard layout 'u'"),
("made-up", "", "Unknown keyboard layout 'made-up'"),
("us", "int", "Unknown keyboard variant 'int' for layout 'us'"),
("gb", "intl", "Unknown keyboard variant 'intl' for layout 'gb'"),
)
for layout, variant, message in invalid:
result = keyboard_validate(layout, variant)
check(
result.returncode == 2 and message in result.stderr,
f"invalid keyboard choice {layout!r}/{variant!r} did not fail friendly/early: "
f"rc={result.returncode}, stderr={result.stderr.strip()!r}",
)
check(
"NOMARCHY_INSTALL_SHARE" not in result.stderr,
"invalid keyboard choice reached normal installer setup",
)
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")