"""Lint an image's HardwareDriver.json against PESetup's real driver matcher. A port of scripts/lint-driver-catalogue.py for the webapp (the script is left as it is: it still gates deploys from a workstation). Same matcher, same checks, with three differences that come from running on the server: * zips are looked up on the share directly, and a zip found only with different letter case is a WARN, not an ERROR, when WinPE still finds it: WinPE reads the share over SMB and Samba matches each folder name case-insensitively for Windows clients (default "case sensitive = auto"). It stays an ERROR when a folder with the exact spelling exists and the zip sits in its other-case twin; * a missing (null) modelswminame or family is CRITICAL, not just "no tokens": the C# calls .Split / .ToLower on them, which throws for every model the family filter lets through; * "win10-first": a Win11 entry's model is answered by a Win10 entry earlier in the list. Every GE image deploys Windows 11 and PESetup has no OS filter, so those PCs get Windows 10 drivers. Positions in messages are 1-based (#5 = fifth entry), as on the Drivers tab. lint_entries(entries, image_type) -> findings, each a dict: {"level", "check", "message", "index" (0-based or None), "indexes" (every 0-based entry the finding is about)} lint_image(image_type) reads the map from disk (used by the Overview tab). """ import json import os from pathlib import PurePosixPath from services import fs, image_pages CRITICAL, ERROR, WARN, INFO = "CRITICAL", "ERROR", "WARN", "INFO" LEVELS = (CRITICAL, ERROR, WARN, INFO) LEVEL_ORDER = {lvl: i for i, lvl in enumerate(LEVELS)} # Short names for the checks, shown as badges on the Drivers tab. CHECK_LABELS = { "virtual-platform": "virtual platform", "null-tokens": "no modelswminame", "null-family": "no family", "empty-token": "empty token", "token-whitespace": "token whitespace", "no-tokens": "no tokens", "family-mismatch": "family mismatch", "no-filename": "no file name", "missing-zip": "zip missing", "case-mismatch": "zip case differs", "win10-first": "Win10 entry wins", "shadowed": "shadowed", "duplicate-token": "duplicate token", "bad-entry": "not an object", } # --- PESetup's matcher (docs/PESETUP-INTERNALS.md, GetDriverByModel) --------- def model_family(model): """Three Contains() tests, the last hit wins, "" when none hit.""" upper = model.upper() family = "" if "LATITUDE" in upper: family = "Latitude" if "OPTIPLEX" in upper: family = "Optiplex" if "PRECISION" in upper: family = "Precision" return family def entry_tokens(entry): """modelswminame split on comma. NOT trimmed: the C# does not trim. A missing (null) modelswminame gives no tokens here; PESetup throws on it instead, which the null-tokens check reports.""" if entry.get("modelswminame") is None: return [] return str(entry.get("modelswminame")).split(",") def entry_matches(entry, model): family = model_family(model) if family and family.lower() not in str(entry.get("family") or "").lower(): return False # model.ToLower().Contains(token.ToLower()); "" is in every string. return any(t.lower() in model.lower() for t in entry_tokens(entry)) def select_driver(entries, model): """First match wins, like GetDriverByModel: (index, entry) or (None, None).""" for i, entry in enumerate(entries): if entry_matches(entry, model): return i, entry return None, None # --- helpers ------------------------------------------------------------------- def label(entry, index): name = entry.get("modelsfriendlyname") or entry.get("modelswminame") or "?" return "#%d %s" % (index + 1, name) def _smb_lookup(root, rel): """Resolve rel the way Samba does for a Windows client: per folder, the exact name when it exists, else the one name that differs only in case. No backtracking: once a folder is picked, a file under its other-case twin is not found (Dell_11 has held both Optiplex and OptiPlex).""" cur = root for part in PurePosixPath(rel).parts: nxt = os.path.join(cur, part) if not os.path.lexists(nxt): try: names = [n for n in os.listdir(cur) if n.lower() == part.lower()] except OSError: return None if len(names) != 1: return None nxt = os.path.join(cur, names[0]) cur = nxt return cur if os.path.isfile(cur) else None def zip_state(image_type, entry): """Where an entry's zip is: {"rel", "status", "path"}. rel is relative to the image root (Deploy/Out-of-box Drivers/...). status: ok the exact path exists case only with other letter case, and WinPE (SMB) still finds it trap on disk under another-case folder that SMB does not pick missing nowhere none the entry names no file""" rel = image_pages.hardware_coverage._zip_relpath(entry) if not rel: return {"rel": "", "status": "none", "path": None} if ".." in PurePosixPath(rel).parts: # Never look outside the image tree: /drivers/check takes any # posted entry, and no real entry climbs out with "..". return {"rel": rel, "status": "missing", "path": None} root = fs.image_root(image_type) exact = os.path.join(root, rel) if os.path.isfile(exact): return {"rel": rel, "status": "ok", "path": exact} smb = _smb_lookup(root, rel) if smb: return {"rel": rel, "status": "case", "path": smb, "actual": os.path.relpath(smb, root)} found = image_pages.zip_path(image_type, entry) if found: return {"rel": rel, "status": "trap", "path": found, "actual": os.path.relpath(found, root)} return {"rel": rel, "status": "missing", "path": None} def _add(out, level, check, message, index=None, indexes=None): idx = list(indexes) if indexes is not None else ([] if index is None else [index]) out.append({"level": level, "check": check, "message": message, "index": index, "indexes": idx}) # --- checks -------------------------------------------------------------------- def lint_entries(entries, image_type=None, zips=None): """Every finding for a list of entries. image_type enables the zip checks; zips (a list of zip_state results, one per entry) saves looking them up again.""" out = [] rows = [e if isinstance(e, dict) else {} for e in entries] for i, e in enumerate(entries): if not isinstance(e, dict): _add(out, CRITICAL, "bad-entry", "#%d is not a JSON object; PESetup cannot read the map." % (i + 1), i) for i, entry in enumerate(rows): tag = label(entry, i) # Virtual platform hijack: one such entry anywhere flips PESetup into # virtual-only mode and every physical machine fails. manufacturer = str(entry.get("manufacturer") or "") if "virtual platform" in manufacturer.lower(): _add(out, CRITICAL, "virtual-platform", "%s manufacturer is %r. PESetup then takes the first entry as the driver and " "REQUIRES the machine to look virtual, so every physical PC fails." % (tag, manufacturer), i) # Null fields the C# dereferences without a check. if entry.get("modelswminame") is None: _add(out, CRITICAL, "null-tokens", "%s has no modelswminame. PESetup calls Split() on it and throws for every " "model the family filter lets through, so driver selection fails." % tag, i) if entry.get("family") is None: _add(out, CRITICAL, "null-family", "%s has no family. PESetup calls ToLower() on it for every Latitude, OptiPlex " "and Precision model and throws." % tag, i) # Empty token: Contains("") is true for every model. An empty # modelswminame is one empty token, so it matches everything too. tokens = entry_tokens(entry) if tokens == [""]: _add(out, CRITICAL, "empty-token", "%s modelswminame is empty. Split gives one empty token, which matches every " "model, so this entry takes over the map." % tag, i) elif any(t == "" for t in tokens): _add(out, CRITICAL, "empty-token", "%s modelswminame %r has an EMPTY token (trailing or doubled comma). An empty " "token matches every model, so this entry takes over the map." % (tag, entry.get("modelswminame")), i) # Whitespace around a token is kept by Split(','), so it must be in the model. for token in tokens: if token and token != token.strip(): _add(out, ERROR, "token-whitespace", "%s token %r carries whitespace. PESetup does not trim, so the model " "string must contain the space too; this token will usually miss." % (tag, token), i) if tokens and tokens != [""] and not str(entry.get("modelswminame")).strip(): _add(out, ERROR, "no-tokens", "%s has an empty modelswminame, so no model can ever select it." % tag, i) # The family filter runs on the MODEL first, then wants entry.family # to contain it: a token implying another family can never match. family_field = str(entry.get("family") or "") for token in tokens: token = token.strip() implied = model_family(token) if token else "" if implied and implied.lower() not in family_field.lower(): _add(out, ERROR, "family-mismatch", "%s token %r implies family %s, but family is %r. The filter runs before " "the token test, so this token can never match." % (tag, token, implied, family_field), i) # The referenced zip. if not str(entry.get("fileName") or entry.get("FileName") or ""): _add(out, ERROR, "no-filename", "%s has no fileName, so PESetup has nothing to unzip." % tag, i) elif image_type: z = zips[i] if zips is not None else zip_state(image_type, entry) if z["status"] == "missing": _add(out, ERROR, "missing-zip", "%s references %s, which is not on the share. A driver miss is only a " "warning in PESetup, so imaging finishes with no drivers." % (tag, z["rel"]), i) elif z["status"] == "case": _add(out, WARN, "case-mismatch", "%s references %s but the share holds %s. WinPE finds it (Samba matches " "case-insensitively), Linux tools and the lint script do not." % (tag, z["rel"], z.get("actual", "")), i) elif z["status"] == "trap": _add(out, ERROR, "case-mismatch", "%s references %s but the zip is at %s. A folder with the exact spelling " "exists and does not hold it, so WinPE will not find it." % (tag, z["rel"], z.get("actual", "")), i) # Shadowing: replay each entry's own tokens through the matcher. When a # Win10 entry answers a Win11 entry's model, that is worse than dead # weight: Win11 PCs get Windows 10 drivers. os_of = [image_pages.driver_os(e) for e in rows] for i, entry in enumerate(rows): for token in entry_tokens(entry): token = token.strip() if not token: continue win, _ = select_driver(rows, token) if win is None or win == i: continue if os_of[i] == "Win11" and os_of[win] == "Win10": _add(out, ERROR, "win10-first", "%s token %r is answered by %s, a Windows 10 pack earlier in the list. " "PESetup has no OS filter, so Windows 11 PCs get Windows 10 drivers. " "Move the Win11 entry above it." % (label(entry, i), token, label(rows[win], win)), i, indexes=[i, win]) else: _add(out, WARN, "shadowed", "%s token %r is answered by %s instead (first match wins)." % (label(entry, i), token, label(rows[win], win)), i) # One token claimed by several entries: the first one wins whatever the OS. by_token = {} for i, entry in enumerate(rows): for token in entry_tokens(entry): token = token.strip().lower() if token: by_token.setdefault(token, []) if i not in by_token[token]: by_token[token].append(i) for token, idxs in sorted(by_token.items()): if len(idxs) > 1: detail = ", ".join("%s (%s, osId %s)" % (label(rows[i], i), os_of[i], rows[i].get("osId")) for i in idxs) _add(out, WARN, "duplicate-token", "token %r is claimed by %d entries: %s. osId is not part of the match, so %s " "wins whatever OS is applied." % (token, len(idxs), detail, label(rows[idxs[0]], idxs[0])), None, indexes=idxs) out.sort(key=lambda f: (LEVEL_ORDER[f["level"]], f["indexes"][0] if f["indexes"] else -1)) return out def counts(findings): """{"total", "CRITICAL": n, "ERROR": n, "WARN": n, "INFO": n}.""" c = {lvl: 0 for lvl in LEVELS} for f in findings: c[f["level"]] += 1 c["total"] = sum(c[lvl] for lvl in LEVELS) return c def map_path(image_type): return image_pages.control_file(image_type, "HardwareDriver.json") def lint_image(image_type): """Findings for the map on disk. [] when the image has no map; one CRITICAL finding when it does not parse or is not a list.""" path = map_path(image_type) if not os.path.isfile(path): return [] try: with open(path, encoding="utf-8-sig") as fh: entries = json.load(fh) except (OSError, ValueError) as exc: return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [], "message": "HardwareDriver.json does not parse: %s" % exc}] if not isinstance(entries, list): return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [], "message": "HardwareDriver.json is a %s, expected a list" % type(entries).__name__}] return lint_entries(entries, image_type)