Some checks failed
Check / eval (push) Has been cancelled
- #112: list_installable_disks drops fd/loop/sr/zram/<8GiB, largest-first so OVMF no longer offers /dev/fd0 as the default wipe target - #106: tools/check-menu-back.py + checks.menu-back (proved to fail) - #113: offline theme-switch contract documented (default/pinned only); theme-sync run_switch adds offline-oriented hint when no network Verified: V0 flake check; installer-safety; menu-back; py_compile.
369 lines
14 KiB
Python
Executable File
369 lines
14 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",
|
|
)
|
|
|
|
# ── 1b. Disk picker: exclude floppy/pseudo/tiny (#112) ─────────────────
|
|
check(
|
|
"list_installable_disks" in script,
|
|
"missing list_installable_disks helper (#112)",
|
|
)
|
|
check(
|
|
"/dev/fd*" in script or r"/dev/fd*" in script,
|
|
"disk filter must exclude /dev/fd* (floppy — OVMF lists fd0 first)",
|
|
)
|
|
check(
|
|
"MIN_DISK_BYTES" in script,
|
|
"disk filter must enforce a minimum size floor",
|
|
)
|
|
check(
|
|
"8 * 1024 * 1024 * 1024" in script or "8 * 1024**3" in script
|
|
or "$((8 * 1024 * 1024 * 1024))" in script,
|
|
"default min disk size should be 8 GiB (catches fd0 and crumbs)",
|
|
)
|
|
|
|
# ── 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")
|