diff --git a/scripts/lint-unattend.py b/scripts/lint-unattend.py new file mode 100755 index 0000000..1f378b4 --- /dev/null +++ b/scripts/lint-unattend.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +lint-unattend.py - Validate unattend answer files before they reach a bay. + +Well-formed XML is not enough. Windows Setup validates against a schema, and ONE +bad value invalidates the WHOLE answer file for that pass - the machine stops at +"Windows could not parse or process unattend answer file ... The answer file is +invalid" with nothing configured, and the only clue is a line in +C:\\Windows\\Panther\\setupact.log naming an XPath. + +That is not hypothetical. On 2026-08-06 every shopfloor, standard and engineer +build was failing this way: + + /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path + Description = Value is invalid. hrResult = 0x80220005 pass = specialize + +The cause was an inlined "powershell.exe -Command ..." that had grown to 676 +characters in a field capped at 259. It had been broken since the previous +evening. Every check in this script is mechanical and would have caught it before +a single bay was booted. + +Checks: + path-too-long RunSynchronousCommand/Path > 259 chars + cmdline-too-long SynchronousCommand/CommandLine > 1024 chars + description-too-long Description > 256 chars + duplicate-element an element that may appear once appears twice + not-well-formed XML does not parse + bom UTF-8 BOM present (the live files carry none; adding one + is an unintended diff, and python's utf-8-sig ADDS one) + unknown-token %token% left in the file that PESetup will not substitute + (it only replaces %serialnumber% and *arch*) + +Usage: + ./lint-unattend.py # every image type on the PXE server + ./lint-unattend.py --image gea-shopfloor + ./lint-unattend.py --local playbook/FlatUnattendW10.xml ... + ./lint-unattend.py --quiet + +Exits non-zero when any ERROR is found, so it can gate a deploy. + +Requires: sshpass (remote mode only) +""" + +import argparse +import re +import subprocess +import sys +import xml.dom.minidom + +PXE_HOST = "172.16.9.1" +PXE_USER = "pxe" +PXE_PASS = "pxe" +IMAGE_BASE = "/srv/samba/winpeapps" +UNATTEND_REL = "Deploy/FlatUnattendW10.xml" + +# Schema limits. Exceeding any of these invalidates the entire answer file for +# the pass the element sits in, not just the one command. +MAX_PATH = 259 # Microsoft-Windows-Deployment RunSynchronousCommand/Path +MAX_COMMANDLINE = 1024 # Microsoft-Windows-Shell-Setup SynchronousCommand/CommandLine +MAX_DESCRIPTION = 256 # Description on either of the above + +# PESetup substitutes exactly these two and nothing else (docs/PESETUP-INTERNALS.md). +KNOWN_TOKENS = {"%serialnumber%"} + +# Windows expands these itself at run time, so they are not PESetup's problem and +# must not be reported. Without this the check cries wolf on every %WINDIR%. +SHELL_VARS = {"%windir%", "%systemroot%", "%systemdrive%", "%programfiles%", + "%programdata%", "%temp%", "%tmp%", "%userprofile%", "%appdata%", + "%localappdata%", "%computername%", "%username%", "%public%", + "%allusersprofile%", "%path%"} + +# Elements that may appear at most once inside a command block. +SINGLE_OCCURRENCE = ("Order", "Description", "Path", "CommandLine", "RequiresUserInput", "WillReboot") + +ERROR, WARN = "ERROR", "WARN" + + +def ssh_cmd(host, cmd): + return subprocess.run( + ["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no", + "-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd], + capture_output=True, text=True) + + +def read_remote_bytes(host, path): + """Base64 in transit so a BOM or CRLF survives the hop intact.""" + r = ssh_cmd(host, "base64 -w0 '%s' 2>/dev/null" % path) + if r.returncode != 0 or not r.stdout.strip(): + return None + import base64 + return base64.b64decode(r.stdout.strip()) + + +def read_local_bytes(path): + try: + with open(path, "rb") as f: + return f.read() + except OSError: + return None + + +def blocks(raw, tag): + return re.findall(r"<%s\b.*?" % (tag, tag), raw, re.S) + + +def order_of(block): + m = re.search(r"(\d+)", block) + return m.group(1) if m else "?" + + +def check(name, data, findings): + if data is None: + findings.append((ERROR, "missing", f"{name}: cannot read the file")) + return + + if data.startswith(b"\xef\xbb\xbf"): + findings.append((WARN, "bom", + f"{name}: file carries a UTF-8 BOM. The live files have none - " + "this is usually an accidental diff from a tool that wrote " + "utf-8-sig.")) + raw = data.decode("utf-8-sig") + else: + raw = data.decode("utf-8", errors="replace") + + try: + xml.dom.minidom.parseString(raw.encode("utf-8")) + except Exception as e: + findings.append((ERROR, "not-well-formed", f"{name}: XML does not parse: {e}")) + return + + for tag, field, limit in (("RunSynchronousCommand", "Path", MAX_PATH), + ("SynchronousCommand", "CommandLine", MAX_COMMANDLINE)): + for b in blocks(raw, tag): + m = re.search(r"<%s>(.*?)" % (field, field), b, re.S) + if m and len(m.group(1)) > limit: + findings.append((ERROR, "%s-too-long" % field.lower(), + f"{name}: {tag} Order {order_of(b)} has {field} of " + f"{len(m.group(1))} chars, limit {limit}. This invalidates " + f"the WHOLE answer file for its pass. Move the body into a " + f"script under Deploy\\Applications and call it by path.")) + + for tag in ("RunSynchronousCommand", "SynchronousCommand"): + for b in blocks(raw, tag): + for d in re.findall(r"(.*?)", b, re.S): + if len(d) > MAX_DESCRIPTION: + findings.append((ERROR, "description-too-long", + f"{name}: {tag} Order {order_of(b)} has a Description of " + f"{len(d)} chars, limit {MAX_DESCRIPTION}. Put the " + f"rationale in an XML comment instead.")) + for el in SINGLE_OCCURRENCE: + n = len(re.findall(r"<%s>" % el, b)) + if n > 1: + findings.append((ERROR, "duplicate-element", + f"{name}: {tag} Order {order_of(b)} has {n} <{el}> " + f"elements; at most one is allowed.")) + + for tok in sorted(set(re.findall(r"%[a-zA-Z_]+%", raw))): + if tok.lower() not in KNOWN_TOKENS and tok.lower() not in SHELL_VARS: + findings.append((WARN, "unknown-token", + f"{name}: {tok} is not substituted by PESetup (it replaces only " + f"%serialnumber% and *arch*), so it reaches Windows literally.")) + + +def main(): + parser = argparse.ArgumentParser(description="Validate unattend answer files.") + parser.add_argument("--server", default=PXE_HOST, help=f"PXE server (default: {PXE_HOST})") + parser.add_argument("--image", help="single image type instead of all") + parser.add_argument("--local", nargs="+", metavar="FILE", help="lint local files instead") + parser.add_argument("--quiet", action="store_true", help="suppress WARN lines") + args = parser.parse_args() + + findings = [] + targets = [] + + if args.local: + for f in args.local: + targets.append((f, read_local_bytes(f))) + else: + if args.image: + images = [args.image] + else: + r = ssh_cmd(args.server, + "find '%s' -maxdepth 1 -mindepth 1 -type d -printf '%%f\\n'" % IMAGE_BASE) + if r.returncode != 0: + sys.exit("ERROR: cannot list %s: %s" % (IMAGE_BASE, r.stderr.strip())) + images = sorted(n for n in r.stdout.split() if not n.startswith("_")) + for img in images: + path = f"{IMAGE_BASE}/{img}/{UNATTEND_REL}" + targets.append((img, read_remote_bytes(args.server, path))) + + for name, data in targets: + check(name, data, findings) + + print("Linted %d unattend file(s)\n" % len(targets)) + shown = [f for f in findings if not (args.quiet and f[0] == WARN)] + for level, kind, msg in sorted(shown, key=lambda f: 0 if f[0] == ERROR else 1): + print(" %-6s %-22s %s" % (level, kind, msg)) + if not shown: + print(" clean") + + errors = sum(1 for f in findings if f[0] == ERROR) + print("\n%d error, %d warn" % (errors, sum(1 for f in findings if f[0] == WARN))) + if errors: + print("FAILED: do not deploy this answer file.") + return 1 + print("PASSED") + return 0 + + +if __name__ == "__main__": + sys.exit(main())