fix(install): normalize empty keyboard variants
All checks were successful
Check / eval (push) Successful in 3m18s

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.
This commit is contained in:
2026-07-13 15:01:51 +01:00
parent 7bfe1af5b1
commit 9d0abe5422
9 changed files with 179 additions and 23 deletions

View File

@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Installer safety guards contract (flake check `installer-safety`).
Usage: check-install-safety.py <nomarchy-install.sh>
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:
@@ -11,16 +11,27 @@ silent regression:
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).
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")
@@ -165,4 +176,84 @@ check(
"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")