Files
ImpTune/imptune/services/inf_parser.py
T
kawaandClaude Opus 4.6 27ddc77ee1 fix(inf-parser): tolerate bare-line sections in real vendor INFs
Real INFs (e.g. Ricoh oemsetup.inf) include [SourceDisksFiles] entries
with bare filename lines (no '='), which strict configparser rejects
with ParsingError, surfacing as a 500 on /drivers/upload.

Pre-process the INF text to rewrite bare lines into synthetic
__bare_N = <line> entries before parsing, and filter those synthetic
keys out of DriverDesc extraction so they cannot leak into driver_names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:11:45 +02:00

203 lines
8.3 KiB
Python

"""
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 = <original>`` 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<arch> 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
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,
)