""" INF parser service for Windows driver INF files. Extracts driver names (DriverDesc), resolves %TOKEN% references, auto-detects encoding (ANSI/UTF-8/UTF-16), handles multi-model INFs, detects unused files, architecture, and presence of .cat files. Source: Microsoft WDK — General Syntax Rules for INF Files https://learn.microsoft.com/en-us/windows-hardware/drivers/install/general-syntax-rules-for-inf-files """ from __future__ import annotations import configparser import re from dataclasses import dataclass, field @dataclass class ParsedInf: """Result of parsing a Windows INF file.""" driver_names: list[str] # resolved DriverDesc values, deduplicated and sorted inf_filename: str # which .inf file was parsed (basename from ZIP) architecture: str | None # 'x64', 'x86', 'arm64', or None if ambiguous/unknown has_cat_file: bool # True if a .cat file exists in the ZIP member list unused_files: list[str] # ZIP members not referenced anywhere in the INF text def _detect_encoding(raw: bytes) -> str: """Detect INF file encoding by sniffing BOM bytes. INF files from real vendors arrive as: - ANSI / Windows-1252 (cp1252) — most legacy drivers - UTF-8 with BOM — modern drivers - UTF-16 LE with BOM — HP/Canon x64 signed drivers (most common UTF-16) - UTF-16 BE with BOM — rare Source: Microsoft WDK — general-syntax-rules-for-inf-files """ if raw[:2] in (b"\xff\xfe", b"\xfe\xff"): return "utf-16" # UTF-16 LE or BE with BOM if raw[:3] == b"\xef\xbb\xbf": return "utf-8-sig" # UTF-8 with BOM return "cp1252" # ANSI / Windows-1252 safe fallback def _neutralize_bare_lines(inf_text: str) -> str: """Rewrite INF lines that lack ``=`` so configparser can ingest the file. Real vendor INFs contain sections like ``[SourceDisksFiles]`` or copy-list sections whose entries are bare filenames (no key/value). configparser is strict and aborts on those. We only care about ``key = value`` lines for DriverDesc extraction, so it's safe to convert each bare payload line into a synthetic ``__bare_N = `` entry. """ out: list[str] = [] counter = 0 for line in inf_text.splitlines(): stripped = line.strip() if ( not stripped or stripped.startswith(";") or stripped.startswith("#") or stripped.startswith("[") or "=" in stripped ): out.append(line) continue counter += 1 out.append(f"__bare_{counter} = {stripped}") return "\n".join(out) def _resolve_tokens(value: str, strings: dict[str, str]) -> str: """Expand %TOKEN% placeholders using the [Strings] section lookup dict. Keys in ``strings`` must already be lowercased (configparser lowercases option names by default). Source: Microsoft WDK — general-syntax-rules-for-inf-files — strkey% syntax """ def replacer(match: re.Match) -> str: key = match.group(1).lower() return strings.get(key, match.group(0)) return re.sub(r"%([^%]+)%", replacer, value) def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf: """Parse a Windows INF file and extract driver metadata. Args: inf_text: Decoded text content of the .inf file. inf_filename: Name of the .inf file (used as-is in the result). zip_names: List of all member paths from the containing ZIP archive. Used for unused-file detection and .cat presence check. Returns: ParsedInf dataclass with driver_names, architecture, has_cat_file, unused_files, and inf_filename. Notes: - Uses RawConfigParser (NOT ConfigParser) to avoid %(interpolation)s interference with %TOKEN% INF syntax. - strict=False is required because real INF files frequently have duplicate option keys within a section (multiple hardware IDs). - Architecture detection: if exactly one arch hint found -> return it; multiple arch hints -> None (ambiguous / multi-arch INF). - driver_names are sorted for deterministic dropdown order. """ parser = configparser.RawConfigParser( comment_prefixes=(";", "#"), strict=False, # real INFs have duplicate keys delimiters=("=",), ) # Preserve original option-key casing so DriverDesc literals keep their case. # configparser lowercases keys by default, which would mangle "Acme SuperPrint 9000" # into "acme superprint 9000". We disable that behaviour here and manually lowercase # only when building the [Strings] lookup dict. parser.optionxform = str # type: ignore[assignment] parser.read_string(_neutralize_bare_lines(inf_text)) # Build strings lookup with LOWERCASED keys (case-insensitive token resolution). # INF token references are case-insensitive per WDK spec. strings: dict[str, str] = {} if parser.has_section("Strings"): for key, val in parser.items("Strings"): # INF string values are typically surrounded by double-quotes; strip them. strings[key.lower()] = val.strip('"') # Collect Models section base names from [Manufacturer] # Format per WDK: mfg-id = models-section-name[,target-OS-version[,target-OS-version...]] models_section_names: list[str] = [] if parser.has_section("Manufacturer"): for _mfg_key, mfg_val in parser.items("Manufacturer"): # Resolve any %TOKEN% in the manufacturer value (rare, but safe) resolved_val = _resolve_tokens(mfg_val, strings) parts = [p.strip() for p in resolved_val.split(",")] if parts: models_section_names.append(parts[0]) # For each referenced Models section base name, find all matching sections # (undecorated, .NTamd64, .NTarm64, .NTx86, etc.) and extract DriverDesc entries. driver_names: set[str] = set() arch_hints: set[str] = set() all_sections_lower = {s.lower(): s for s in parser.sections()} for base_name in models_section_names: base_lower = base_name.lower() for section_lower, section in all_sections_lower.items(): # Match: exact base name (undecorated) OR base name + .NT decoration if section_lower == base_lower: # Undecorated section — architecture hint: x86 arch_hints.add("x86") suffix = "" elif section_lower.startswith(base_lower + ".nt"): suffix = section_lower[len(base_lower):] # e.g. ".ntamd64" if "amd64" in suffix: arch_hints.add("x64") elif "arm64" in suffix: arch_hints.add("arm64") elif "x86" in suffix: arch_hints.add("x86") else: # Generic .NT decoration (no specific arch) — treat as x86 arch_hints.add("x86") else: continue # Each option key in a Models section is a device-description (DriverDesc) for key, _val in parser.items(section): if key.startswith("__bare_"): continue # DriverDesc keys are sometimes quoted directly in the INF # (e.g. `"Canon Generic PCL6" = SectionName, HardwareID`) # instead of via %TOKEN%; configparser keeps those quotes as # part of the key, so strip them same as [Strings] values. resolved = _resolve_tokens(key, strings).strip('"') # Skip empty, purely numeric, or clearly non-driver-name entries if resolved and not resolved.isdigit(): driver_names.add(resolved) # Architecture: unambiguous only when exactly one arch hint found architecture: str | None = arch_hints.pop() if len(arch_hints) == 1 else None # .cat file detection has_cat_file = any(name.lower().endswith(".cat") for name in zip_names) # Unused files: ZIP members whose basename does not appear anywhere in INF text inf_lower = inf_text.lower() unused_files: list[str] = [] for member in zip_names: # Normalise path separators, then take basename basename = member.replace("\\", "/").rsplit("/", 1)[-1] if basename.lower() not in inf_lower: unused_files.append(member) return ParsedInf( driver_names=sorted(driver_names), inf_filename=inf_filename, architecture=architecture, has_cat_file=has_cat_file, unused_files=unused_files, )