"""Unattend.xml parser, form-edit applier, linter and builder.
Round-trips a Windows ``FlatUnattendW10.xml`` between the disk format
and a flat dict the editor template can render. ``parse_unattend()``
reads disk + returns a dict; ``apply_form()`` writes a form dict back
INTO the existing file text (see "Form edits" below: only what changed
is touched, everything else stays byte for byte); ``lint_text()`` is
the webapp copy of scripts/lint-unattend.py; ``extract_form_data()``
converts a Flask ``request.form`` MultiDict into the form dict.
``build_unattend_xml()`` (a fresh file from a dict) is kept for callers
that want one, but no save path uses it any more: it dropped comments,
rewrote *arch* and re-flowed every attribute.
"""
import html
import os
import re
from lxml import etree
import config
UNATTEND_TEMPLATE = """\
"""
def qn(tag):
"""Return a tag qualified with the default unattend namespace."""
return f"{{{config.UNATTEND_NS}}}{tag}"
def qwcm(attr):
"""Return an attribute qualified with the wcm namespace."""
return f"{{{config.WCM_NS}}}{attr}"
def _find_or_create(parent, tag):
"""Find the first child with *tag* or create it."""
el = parent.find(tag, namespaces={"": config.UNATTEND_NS})
if el is None:
el = etree.SubElement(parent, qn(tag.split("}")[-1]) if "}" not in tag else tag)
return el
def _settings_pass(root, pass_name):
"""Return the element, creating if needed."""
for s in root.findall(qn("settings")):
if s.get("pass") == pass_name:
return s
s = etree.SubElement(root, qn("settings"))
s.set("pass", pass_name)
return s
def _empty_data(defaults):
"""The dict parse_unattend returns. OOBE values: with defaults (no file
yet) the settings a new answer file gets; for an existing file a
setting that is not in it is "" (not set), so the form shows what the
file holds and a save does not add settings nobody chose."""
data = {
"driver_paths": [],
"computer_name": "",
"registered_organization": "",
"registered_owner": "",
"time_zone": "",
"specialize_commands": [],
"oobe": {
"HideEULAPage": "true",
"HideOEMRegistrationScreen": "true",
"HideOnlineAccountScreens": "true",
"HideWirelessSetupInOOBE": "true",
"HideLocalAccountScreen": "true",
"NetworkLocation": "Work",
"ProtectYourPC": "3",
"SkipUserOOBE": "true",
"SkipMachineOOBE": "true",
},
"firstlogon_commands": [],
"user_accounts": [],
"autologon": {
"enabled": "",
"username": "",
"password": "",
"plain_text": "true",
"logon_count": "",
},
"intl": {
"input_locale": "",
"system_locale": "",
"ui_language": "",
"user_locale": "",
},
"oobe_timezone": "",
"raw_xml": "",
}
if not defaults:
data["oobe"] = {k: "" for k in data["oobe"]}
return data
def _comment_text(c):
return " ".join((c.text or "").split())
def _notes(el):
"""Comments that belong to a list item: the ones directly above it
(up to the previous element) and the ones inside it. Shown read-only
on the form; they move with the item (see Form edits)."""
lead = []
for sib in el.itersiblings(preceding=True):
if not isinstance(sib, etree._Comment):
break
lead.append(_comment_text(sib))
lead.reverse()
inner = [_comment_text(c) for c in el.iter(etree.Comment)]
return {"note": [t for t in lead if t], "inner_note": [t for t in inner if t]}
def parse_unattend(xml_path):
"""Parse an unattend.xml and return a dict of editable data."""
if not os.path.isfile(xml_path):
data = _empty_data(defaults=True)
data["raw_xml"] = UNATTEND_TEMPLATE
return data
# errors="replace": a stray non-UTF-8 byte must not take the page down;
# the save path refuses a form save on such a file (views/images_unattend).
with open(xml_path, "r", encoding="utf-8", errors="replace") as fh:
return parse_unattend_text(fh.read())
def parse_unattend_text(raw):
"""parse_unattend for text already read (a BOM, if any, is ignored)."""
data = _empty_data(defaults=False)
data["raw_xml"] = raw
try:
root = etree.fromstring(raw.lstrip("\ufeff").encode("utf-8"))
except etree.XMLSyntaxError:
return data
ns = {"u": config.UNATTEND_NS}
# --- offlineServicing: DriverPaths ---
# By local name: the shopfloor file declares xmlns="" on
# PathAndCredentials, which takes it (and Path) OUT of the unattend
# namespace. A namespaced match missed it, and the old form save then
# dropped W:\Drivers from that image.
for dp_el in root.xpath(
"u:settings[@pass='offlineServicing']//*[local-name()='PathAndCredentials']"
"/*[local-name()='Path']",
namespaces=ns,
):
data["driver_paths"].append((dp_el.text or "").strip())
# --- specialize: Shell-Setup ---
for comp in root.xpath("u:settings[@pass='specialize']/u:component", namespaces=ns):
comp_name = comp.get("name", "")
if "Shell-Setup" in comp_name:
for tag, key in [
("ComputerName", "computer_name"),
("RegisteredOrganization", "registered_organization"),
("RegisteredOwner", "registered_owner"),
("TimeZone", "time_zone"),
]:
el = comp.find(qn(tag))
if el is not None and el.text:
data[key] = el.text.strip()
# --- specialize: RunSynchronous commands ---
for cmd in root.xpath(
"u:settings[@pass='specialize']//u:RunSynchronousCommand",
namespaces=ns,
):
order_el = cmd.find(qn("Order"))
path_el = cmd.find(qn("Path"))
desc_el = cmd.find(qn("Description"))
data["specialize_commands"].append(dict({
"order": order_el.text.strip() if order_el is not None and order_el.text else "",
"path": path_el.text.strip() if path_el is not None and path_el.text else "",
"description": desc_el.text.strip() if desc_el is not None and desc_el.text else "",
}, **_notes(cmd)))
# --- oobeSystem ---
for comp in root.xpath("u:settings[@pass='oobeSystem']/u:component", namespaces=ns):
comp_name = comp.get("name", "")
if "International-Core" in comp_name:
for tag, key in [
("InputLocale", "input_locale"),
("SystemLocale", "system_locale"),
("UILanguage", "ui_language"),
("UserLocale", "user_locale"),
]:
el = comp.find(qn(tag))
if el is not None and el.text:
data["intl"][key] = el.text.strip()
if "OOBE" in comp_name or "Shell-Setup" in comp_name:
oobe_el = comp.find(qn("OOBE"))
if oobe_el is not None:
for child in oobe_el:
local = etree.QName(child).localname
if local in data["oobe"] and child.text:
data["oobe"][local] = child.text.strip()
ua = comp.find(qn("UserAccounts"))
if ua is not None:
la_container = ua.find(qn("LocalAccounts"))
if la_container is not None:
for acct in la_container.findall(qn("LocalAccount")):
name_el = acct.find(qn("Name"))
group_el = acct.find(qn("Group"))
display_el = acct.find(qn("DisplayName"))
pw_el = acct.find(qn("Password"))
pw_val = ""
pw_plain = "true"
if pw_el is not None:
v = pw_el.find(qn("Value"))
p = pw_el.find(qn("PlainText"))
if v is not None and v.text:
pw_val = v.text.strip()
if p is not None and p.text:
pw_plain = p.text.strip()
data["user_accounts"].append({
"name": name_el.text.strip() if name_el is not None and name_el.text else "",
"password": pw_val,
"plain_text": pw_plain,
"group": group_el.text.strip() if group_el is not None and group_el.text else "Administrators",
"display_name": display_el.text.strip() if display_el is not None and display_el.text else "",
})
al = comp.find(qn("AutoLogon"))
if al is not None:
enabled_el = al.find(qn("Enabled"))
user_el = al.find(qn("Username"))
count_el = al.find(qn("LogonCount"))
pw_el = al.find(qn("Password"))
pw_val = ""
pw_plain = "true"
if pw_el is not None:
v = pw_el.find(qn("Value"))
p = pw_el.find(qn("PlainText"))
if v is not None and v.text:
pw_val = v.text.strip()
if p is not None and p.text:
pw_plain = p.text.strip()
data["autologon"] = {
"enabled": enabled_el.text.strip() if enabled_el is not None and enabled_el.text else "",
"username": user_el.text.strip() if user_el is not None and user_el.text else "",
"password": pw_val,
"plain_text": pw_plain,
"logon_count": count_el.text.strip() if count_el is not None and count_el.text else "",
}
flc = comp.find(qn("FirstLogonCommands"))
if flc is not None:
for sync in flc.findall(qn("SynchronousCommand")):
order_el = sync.find(qn("Order"))
cl_el = sync.find(qn("CommandLine"))
desc_el = sync.find(qn("Description"))
data["firstlogon_commands"].append(dict({
"order": order_el.text.strip() if order_el is not None and order_el.text else "",
"commandline": cl_el.text.strip() if cl_el is not None and cl_el.text else "",
"description": desc_el.text.strip() if desc_el is not None and desc_el.text else "",
}, **_notes(sync)))
tz_el = comp.find(qn("TimeZone"))
if tz_el is not None and tz_el.text:
data["oobe_timezone"] = tz_el.text.strip()
return data
def build_unattend_xml(form_data):
"""Build a complete unattend.xml string from form data dict."""
root = etree.Element(qn("unattend"), nsmap=config.NSMAP)
_settings_pass(root, "windowsPE")
# --- offlineServicing: DriverPaths ---
offline = _settings_pass(root, "offlineServicing")
driver_paths = form_data.get("driver_paths", [])
if driver_paths:
comp = etree.SubElement(offline, qn("component"))
comp.set("name", "Microsoft-Windows-PnpCustomizationsNonWinPE")
comp.set("processorArchitecture", "amd64")
comp.set("publicKeyToken", "31bf3856ad364e35")
comp.set("language", "neutral")
comp.set("versionScope", "nonSxS")
dp_container = etree.SubElement(comp, qn("DriverPaths"))
for idx, dp in enumerate(driver_paths, start=1):
if not dp.strip():
continue
pac = etree.SubElement(dp_container, qn("PathAndCredentials"))
pac.set(qwcm("action"), "add")
pac.set(qwcm("keyValue"), str(idx))
path_el = etree.SubElement(pac, qn("Path"))
path_el.text = dp.strip()
# --- specialize ---
spec = _settings_pass(root, "specialize")
shell_comp = etree.SubElement(spec, qn("component"))
shell_comp.set("name", "Microsoft-Windows-Shell-Setup")
shell_comp.set("processorArchitecture", "amd64")
shell_comp.set("publicKeyToken", "31bf3856ad364e35")
shell_comp.set("language", "neutral")
shell_comp.set("versionScope", "nonSxS")
for tag, key in [
("ComputerName", "computer_name"),
("RegisteredOrganization", "registered_organization"),
("RegisteredOwner", "registered_owner"),
("TimeZone", "time_zone"),
]:
val = form_data.get(key, "").strip()
if val:
el = etree.SubElement(shell_comp, qn(tag))
el.text = val
spec_cmds = form_data.get("specialize_commands", [])
if spec_cmds:
deploy_comp = etree.SubElement(spec, qn("component"))
deploy_comp.set("name", "Microsoft-Windows-Deployment")
deploy_comp.set("processorArchitecture", "amd64")
deploy_comp.set("publicKeyToken", "31bf3856ad364e35")
deploy_comp.set("language", "neutral")
deploy_comp.set("versionScope", "nonSxS")
rs = etree.SubElement(deploy_comp, qn("RunSynchronous"))
for idx, cmd in enumerate(spec_cmds, start=1):
if not cmd.get("path", "").strip():
continue
rsc = etree.SubElement(rs, qn("RunSynchronousCommand"))
rsc.set(qwcm("action"), "add")
order_el = etree.SubElement(rsc, qn("Order"))
order_el.text = str(idx)
path_el = etree.SubElement(rsc, qn("Path"))
path_el.text = cmd["path"].strip()
desc_el = etree.SubElement(rsc, qn("Description"))
desc_el.text = cmd.get("description", "").strip()
# --- oobeSystem ---
oobe_settings = _settings_pass(root, "oobeSystem")
intl = form_data.get("intl", {})
if any(v.strip() for v in intl.values() if v):
intl_comp = etree.SubElement(oobe_settings, qn("component"))
intl_comp.set("name", "Microsoft-Windows-International-Core")
intl_comp.set("processorArchitecture", "amd64")
intl_comp.set("publicKeyToken", "31bf3856ad364e35")
intl_comp.set("language", "neutral")
intl_comp.set("versionScope", "nonSxS")
for tag, key in [
("InputLocale", "input_locale"),
("SystemLocale", "system_locale"),
("UILanguage", "ui_language"),
("UserLocale", "user_locale"),
]:
val = intl.get(key, "").strip()
if val:
el = etree.SubElement(intl_comp, qn(tag))
el.text = val
oobe_comp = etree.SubElement(oobe_settings, qn("component"))
oobe_comp.set("name", "Microsoft-Windows-Shell-Setup")
oobe_comp.set("processorArchitecture", "amd64")
oobe_comp.set("publicKeyToken", "31bf3856ad364e35")
oobe_comp.set("language", "neutral")
oobe_comp.set("versionScope", "nonSxS")
oobe_el = etree.SubElement(oobe_comp, qn("OOBE"))
oobe_data = form_data.get("oobe", {})
for key in [
"HideEULAPage",
"HideOEMRegistrationScreen",
"HideOnlineAccountScreens",
"HideWirelessSetupInOOBE",
"HideLocalAccountScreen",
"NetworkLocation",
"ProtectYourPC",
"SkipUserOOBE",
"SkipMachineOOBE",
]:
val = oobe_data.get(key, "")
if val:
child = etree.SubElement(oobe_el, qn(key))
child.text = str(val)
accounts = form_data.get("user_accounts", [])
if accounts:
ua = etree.SubElement(oobe_comp, qn("UserAccounts"))
la_container = etree.SubElement(ua, qn("LocalAccounts"))
for acct in accounts:
if not acct.get("name", "").strip():
continue
la = etree.SubElement(la_container, qn("LocalAccount"))
la.set(qwcm("action"), "add")
pw = etree.SubElement(la, qn("Password"))
pw_val = etree.SubElement(pw, qn("Value"))
pw_val.text = acct.get("password", "")
pw_plain = etree.SubElement(pw, qn("PlainText"))
pw_plain.text = acct.get("plain_text", "true")
name_el = etree.SubElement(la, qn("Name"))
name_el.text = acct["name"].strip()
group_el = etree.SubElement(la, qn("Group"))
group_el.text = acct.get("group", "Administrators").strip()
display_el = etree.SubElement(la, qn("DisplayName"))
display_el.text = acct.get("display_name", acct["name"]).strip()
autologon = form_data.get("autologon", {})
if autologon.get("username", "").strip():
al = etree.SubElement(oobe_comp, qn("AutoLogon"))
al_pw = etree.SubElement(al, qn("Password"))
al_pw_val = etree.SubElement(al_pw, qn("Value"))
al_pw_val.text = autologon.get("password", "")
al_pw_plain = etree.SubElement(al_pw, qn("PlainText"))
al_pw_plain.text = autologon.get("plain_text", "true")
al_enabled = etree.SubElement(al, qn("Enabled"))
al_enabled.text = autologon.get("enabled", "true")
al_user = etree.SubElement(al, qn("Username"))
al_user.text = autologon["username"].strip()
logon_count = autologon.get("logon_count", "").strip()
if logon_count:
al_count = etree.SubElement(al, qn("LogonCount"))
al_count.text = logon_count
fl_cmds = form_data.get("firstlogon_commands", [])
if fl_cmds:
flc = etree.SubElement(oobe_comp, qn("FirstLogonCommands"))
for idx, cmd in enumerate(fl_cmds, start=1):
if not cmd.get("commandline", "").strip():
continue
sc = etree.SubElement(flc, qn("SynchronousCommand"))
sc.set(qwcm("action"), "add")
order_el = etree.SubElement(sc, qn("Order"))
order_el.text = str(idx)
cl_el = etree.SubElement(sc, qn("CommandLine"))
cl_el.text = cmd["commandline"].strip()
desc_el = etree.SubElement(sc, qn("Description"))
desc_el.text = cmd.get("description", "").strip()
oobe_tz = form_data.get("oobe_timezone", "").strip()
if oobe_tz:
tz_el = etree.SubElement(oobe_comp, qn("TimeZone"))
tz_el.text = oobe_tz
xml_bytes = etree.tostring(
root,
pretty_print=True,
xml_declaration=True,
encoding="utf-8",
)
return xml_bytes.decode("utf-8")
def extract_form_data(form):
"""Pull structured data from the submitted form (the field names of the
old one-page editor). A group of fields that is not in the post at all
is left out of the result, so apply_form leaves that part of the file
alone: a post that carries only a few fields must not empty every list
it did not send."""
data = {}
dp_list = form.getlist("driver_path[]")
data["driver_paths"] = [p for p in dp_list if p.strip()]
data["computer_name"] = form.get("computer_name", "")
data["registered_organization"] = form.get("registered_organization", "")
data["registered_owner"] = form.get("registered_owner", "")
data["time_zone"] = form.get("time_zone", "")
spec_paths = form.getlist("spec_cmd_path[]")
spec_descs = form.getlist("spec_cmd_desc[]")
data["specialize_commands"] = []
for i in range(len(spec_paths)):
if spec_paths[i].strip():
data["specialize_commands"].append({
"path": spec_paths[i],
"description": spec_descs[i] if i < len(spec_descs) else "",
})
data["oobe"] = {}
for key in [
"HideEULAPage",
"HideOEMRegistrationScreen",
"HideOnlineAccountScreens",
"HideWirelessSetupInOOBE",
"HideLocalAccountScreen",
"SkipUserOOBE",
"SkipMachineOOBE",
]:
data["oobe"][key] = form.get(f"oobe_{key}", "false")
data["oobe"]["NetworkLocation"] = form.get("oobe_NetworkLocation", "Work")
data["oobe"]["ProtectYourPC"] = form.get("oobe_ProtectYourPC", "3")
fl_cls = form.getlist("fl_cmd_commandline[]")
fl_descs = form.getlist("fl_cmd_desc[]")
data["firstlogon_commands"] = []
for i in range(len(fl_cls)):
if fl_cls[i].strip():
data["firstlogon_commands"].append({
"commandline": fl_cls[i],
"description": fl_descs[i] if i < len(fl_descs) else "",
})
accounts = []
i = 0
while form.get(f"account_name_{i}"):
accounts.append({
"name": form.get(f"account_name_{i}", ""),
"password": form.get(f"account_password_{i}", ""),
"plain_text": form.get(f"account_plaintext_{i}", "true"),
"group": form.get(f"account_group_{i}", "Administrators"),
"display_name": form.get(f"account_display_{i}", ""),
})
i += 1
data["user_accounts"] = accounts
data["autologon"] = {
"enabled": form.get("autologon_enabled", ""),
"username": form.get("autologon_username", ""),
"password": form.get("autologon_password", ""),
"plain_text": form.get("autologon_plaintext", "true"),
"logon_count": form.get("autologon_logoncount", ""),
}
data["intl"] = {
"input_locale": form.get("intl_input_locale", ""),
"system_locale": form.get("intl_system_locale", ""),
"ui_language": form.get("intl_ui_language", ""),
"user_locale": form.get("intl_user_locale", ""),
}
data["oobe_timezone"] = form.get("oobe_timezone", "")
sent = set(form.keys())
groups = {
"driver_paths": {"driver_path[]"},
"computer_name": {"computer_name"},
"registered_organization": {"registered_organization"},
"registered_owner": {"registered_owner"},
"time_zone": {"time_zone"},
"specialize_commands": {"spec_cmd_path[]", "spec_cmd_desc[]"},
"oobe": {f"oobe_{k}" for k in OOBE_KEYS},
"firstlogon_commands": {"fl_cmd_commandline[]", "fl_cmd_desc[]"},
"user_accounts": {k for k in sent if k.startswith("account_")},
"autologon": {k for k in sent if k.startswith("autologon_")},
"intl": {k for k in sent if k.startswith("intl_")},
"oobe_timezone": {"oobe_timezone"},
}
return {k: v for k, v in data.items() if groups[k] & sent}
# ---------------------------------------------------------------------------
# Form edits: apply a form dict to the EXISTING file text
# ---------------------------------------------------------------------------
#
# The live answer files are hand-maintained: comments explain commands,
# processorArchitecture is the "*arch*" token PESetup substitutes, attributes
# are laid out over several lines, the shopfloor file declares xmlns="" on
# PathAndCredentials. Re-serializing the document (lxml, or
# build_unattend_xml) changes all of that, so a save here splices text:
#
# * a value that did not change is not touched (compared with what
# parse_unattend_text reads from the current file);
# * a changed value replaces only the text between its tags;
# * list items (commands, driver paths, accounts) are cut out of the file
# as whole lines and put back in the new order. The comments directly
# above an item, and any inside it, move with it; when an item is
# removed, the comments above it stay and go to the next item;
# * Order (commands) and wcm:keyValue (driver paths) are renumbered as
# little as possible: a removed item leaves a gap, an added or moved one
# takes a free number between its neighbours, and only when there is no
# room do the numbers after it shift (see _numbering);
# * a new element copies its indentation from its siblings (tabs or
# spaces, the file's line endings), a new list item copies its
# sibling's start tag (wcm:action, xmlns="").
#
# The result is then parsed again and compared with the form; any
# difference refuses the save (ApplyError) rather than writing something
# other than what the page showed. Missing keys in the form dict mean
# "unchanged", so the JSON API can send only what it edits.
WCM_DECL = 'xmlns:wcm="%s"' % config.WCM_NS
COMMON_COMPONENT_ATTRS = ('publicKeyToken="31bf3856ad364e35" language="neutral" '
'versionScope="nonSxS"')
OOBE_KEYS = ("HideEULAPage", "HideOEMRegistrationScreen", "HideOnlineAccountScreens",
"HideWirelessSetupInOOBE", "HideLocalAccountScreen", "NetworkLocation",
"ProtectYourPC", "SkipUserOOBE", "SkipMachineOOBE")
INTL_KEYS = (("InputLocale", "input_locale"), ("SystemLocale", "system_locale"),
("UILanguage", "ui_language"), ("UserLocale", "user_locale"))
SHELL_KEYS = (("ComputerName", "computer_name"),
("RegisteredOrganization", "registered_organization"),
("RegisteredOwner", "registered_owner"), ("TimeZone", "time_zone"))
class ApplyError(ValueError):
"""The form cannot be applied to this file exactly; use Raw XML."""
_TOKEN = re.compile(
r""
r"|<\?.*?\?>"
r"|"
r"|]*>"
r"|(?P[^\s>]+)\s*>"
r"|<(?P[^\s/>!?]+)(?P(?:\s+[^\s=/>]+\s*=\s*(?:\"[^\"]*\"|'[^']*'))*)"
r"\s*(?P/?)>",
re.S)
class _Node:
"""One element or comment of the file, as character offsets."""
__slots__ = ("kind", "tag", "qname", "start", "end", "cstart", "cend",
"attrs", "children", "parent")
def __init__(self, kind, tag="", qname="", start=0, attrs="", parent=None):
self.kind, self.tag, self.qname = kind, tag, qname
self.start, self.end = start, None
self.cstart = self.cend = None # content range; None when
self.attrs, self.children, self.parent = attrs, [], parent
def els(self, tag=None):
return [c for c in self.children if c.kind == "el" and (tag is None or c.tag == tag)]
def first(self, *path):
node = self
for tag in path:
found = node.els(tag)
if not found:
return None
node = found[0]
return node
def descendants(self, tag):
out = []
for c in self.els():
if c.tag == tag:
out.append(c)
out.extend(c.descendants(tag))
return out
def attr(self, name):
m = re.search(r"(?:^|\s)(?:[\w.-]+:)?%s\s*=\s*(\"([^\"]*)\"|'([^']*)')" % re.escape(name),
self.attrs)
if not m:
return None
return html.unescape(m.group(2) if m.group(2) is not None else m.group(3))
def _scan(text):
"""Element/comment tree of a well-formed document, as offsets."""
doc = _Node("doc", start=0)
stack = [doc]
for m in _TOKEN.finditer(text):
tok = m.group(0)
if m.group("open"):
q = m.group("open")
n = _Node("el", q.split(":")[-1], q, m.start(), m.group("attrs") or "", stack[-1])
stack[-1].children.append(n)
if m.group("selfclose"):
n.end = m.end()
else:
n.cstart = m.end()
stack.append(n)
elif m.group("close"):
n = stack.pop()
if n.kind != "el" or n.qname != m.group("close"):
raise ApplyError("could not follow the element structure of the file")
n.cend, n.end = m.start(), m.end()
elif tok.startswith("