From 5056922890977a8b17ff44c374274f265f7bc37f Mon Sep 17 00:00:00 2001 From: Kawa Date: Fri, 10 Apr 2026 11:56:51 +0200 Subject: [PATCH] feat(02-01): implement INF parser with encoding detection and token resolution - ParsedInf dataclass: driver_names, inf_filename, architecture, has_cat_file, unused_files - _detect_encoding(): BOM sniffing for UTF-16 LE/BE, UTF-8 BOM, cp1252 fallback - _resolve_tokens(): regex %TOKEN% expansion from [Strings] section dict - parse_inf(): RawConfigParser(strict=False) with optionxform=str to preserve case - Architecture detection: NTamd64->x64, NTarm64->arm64, undecorated->x86, mixed->None - Deduplication via set(); sorted() for deterministic dropdown order - [Rule 1 - Bug] Fixed configparser key lowercasing by setting optionxform=str - All 16 tests pass, zero regressions in 40-test suite --- imptune/services/inf_parser.py | 173 +++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 imptune/services/inf_parser.py diff --git a/imptune/services/inf_parser.py b/imptune/services/inf_parser.py new file mode 100644 index 0000000..8018e3b --- /dev/null +++ b/imptune/services/inf_parser.py @@ -0,0 +1,173 @@ +""" +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 _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(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): + resolved = _resolve_tokens(key, strings) + # 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, + )