"""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"|[^\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("