From 2a665ce72d35920d5e42beb476ef416ca32d34d9 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Thu, 3 Sep 2026 10:34:23 +0200 Subject: [PATCH] Harden bundled component installation --- setup_tool_dynamic.py | 208 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 187 insertions(+), 21 deletions(-) diff --git a/setup_tool_dynamic.py b/setup_tool_dynamic.py index 2e9b8d4..f106c7b 100644 --- a/setup_tool_dynamic.py +++ b/setup_tool_dynamic.py @@ -43,6 +43,136 @@ class ModernWowSetupTool(WowSetupTool): self._download_indeterminate = False super().__init__(root) + def _bundled_manifest_record(self, relative_path): + """Return the expected size/hash record for one bundled payload file.""" + metadata_path = os.path.join( + get_base_path(), + "Payload", + "Fallback", + "versions.json", + ) + wanted = relative_path.replace("\\", "/").casefold() + try: + with open(metadata_path, "r", encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError, ValueError, TypeError) as exc: + raise RuntimeError( + "Could not read bundled component integrity metadata." + ) from exc + + components = data.get("components") if isinstance(data, dict) else None + if not isinstance(components, dict): + raise RuntimeError("Bundled component integrity metadata is invalid.") + + for component in components.values(): + files = component.get("files") if isinstance(component, dict) else None + if not isinstance(files, list): + continue + for record in files: + if not isinstance(record, dict): + continue + path = record.get("path") + if isinstance(path, str) and path.replace("\\", "/").casefold() == wanted: + return record + + raise RuntimeError( + f"No integrity record exists for bundled file {relative_path}." + ) + + def _verified_bundled_file(self, relative_path, label): + """Validate a bundled file before it is allowed to touch the WoW folder.""" + record = self._bundled_manifest_record(relative_path) + source = os.path.join( + get_base_path(), + *relative_path.replace("\\", "/").split("/"), + ) + if not os.path.isfile(source): + raise RuntimeError(f"Bundled file is missing: {relative_path}") + + expected_size = record.get("size") + if not isinstance(expected_size, int) or expected_size <= 0: + raise RuntimeError(f"Invalid bundled size metadata for {relative_path}.") + if os.path.getsize(source) != expected_size: + raise RuntimeError( + f"Bundled {label} has an unexpected size and will not be installed." + ) + + expected_sha = record.get("sha256") + if ( + not isinstance(expected_sha, str) + or len(expected_sha) != 64 + or any(ch not in "0123456789abcdefABCDEF" for ch in expected_sha) + ): + raise RuntimeError(f"Invalid bundled SHA-256 metadata for {relative_path}.") + expected_sha = expected_sha.lower() + + if self._file_sha256(source).lower() != expected_sha: + raise RuntimeError( + f"Bundled {label} failed its SHA-256 integrity check." + ) + + if os.path.splitext(source)[1].lower() in (".dll", ".exe"): + remote_packages._verify_x86_pe(source, f"bundled {label}") + + return source, expected_sha + + def _install_verified_bundled_file(self, relative_path, target_path, label): + source, expected_sha = self._verified_bundled_file(relative_path, label) + if os.path.isfile(target_path): + try: + if self._file_sha256(target_path).lower() == expected_sha: + return + except OSError: + pass + remote_packages._atomic_replace_file(source, target_path) + + def copy_base_files(self, target): + """Install only true base files; component addons are handled with their DLLs.""" + payload_dir = os.path.join(get_base_path(), "Payload") + if not os.path.isdir(payload_dir): + return + + if self.install_autologin.get(): + data_source = os.path.join(payload_dir, "Data") + if os.path.isdir(data_source): + shutil.copytree( + data_source, + os.path.join(target, "Data"), + dirs_exist_ok=True, + ) + + # Do not copy Payload/Interface wholesale here. Nampower and UnitXP + # addons must stay paired with the exact DLL version selected later. + vanilla_fixes, vf_sha = self._verified_bundled_file( + "Payload/VanillaFixes.exe", + "VanillaFixes.exe", + ) + vf_patcher, patcher_sha = self._verified_bundled_file( + "Payload/VfPatcher.dll", + "VfPatcher.dll", + ) + target_fixes = os.path.join(target, "VanillaFixes.exe") + target_patcher = os.path.join(target, "VfPatcher.dll") + + already_current = False + if os.path.isfile(target_fixes) and os.path.isfile(target_patcher): + try: + already_current = ( + self._file_sha256(target_fixes).lower() == vf_sha + and self._file_sha256(target_patcher).lower() == patcher_sha + ) + except OSError: + already_current = False + + if not already_current: + remote_packages._transactional_replace_bundle( + [ + ("file", vanilla_fixes, target_fixes), + ("file", vf_patcher, target_patcher), + ], + label="VanillaFixes", + ) + def _collect_settings(self): settings = super()._collect_settings() settings["discord_presence"] = { @@ -606,9 +736,9 @@ class ModernWowSetupTool(WowSetupTool): f"Could not download the latest {dll_name}, and no bundled fallback exists.\n\n{error}" ) from error - remote_packages._verify_x86_pe( - source_dll, - f"bundled fallback {dll_name}", + self._verified_bundled_file( + f"Payload/{dll_name}", + f"{dll_name} fallback", ) source_addon = None @@ -1053,10 +1183,48 @@ class ModernWowSetupTool(WowSetupTool): except Exception as exc: self._fallback_core_dll(payload_base, target, dll_name, exc) + elif dll_name == "perf_boost.dll": + source_dll, _ = self._verified_bundled_file( + "Payload/perf_boost.dll", + "PerfBoost", + ) + source_addon = os.path.join( + payload_base, + "Interface", + "Addons", + "perfboostsettings", + ) + if not os.path.isdir(source_addon): + raise RuntimeError( + "Bundled PerfBoost settings addon is missing." + ) + remote_packages._transactional_replace_bundle( + [ + ( + "file", + source_dll, + os.path.join(target, "perf_boost.dll"), + ), + ( + "dir", + source_addon, + os.path.join( + target, + "Interface", + "AddOns", + "perfboostsettings", + ), + ), + ], + label="PerfBoost", + ) + else: - source_dll = os.path.join(payload_base, dll_name) - if os.path.exists(source_dll): - shutil.copy2(source_dll, target) + self._install_verified_bundled_file( + f"Payload/{dll_name}", + os.path.join(target, dll_name), + dll_name, + ) dlls_text_lines.append(dll_name) @@ -1093,18 +1261,14 @@ class ModernWowSetupTool(WowSetupTool): # Optional client fixes, in the same order as the UI. no1600_var = self.optional_plugins["no1600x1200.dll"] if no1600_var.get(): - try: - remote_packages.install_no1600x1200( - target, - progress=self._report_download_progress, - ) - except Exception as exc: - self._fallback_core_dll( - payload_base, - target, - "no1600x1200.dll", - exc, - ) + # No1600x1200 comes from an archive/mirror repository rather + # than a maintained release channel. Use the bundled, + # hash-verified known-good copy for predictable installs. + self._install_verified_bundled_file( + "Payload/no1600x1200.dll", + os.path.join(target, "no1600x1200.dll"), + "no1600x1200.dll", + ) dlls_text_lines.append("no1600x1200.dll") if self.vmmfix_enabled.get(): @@ -1175,9 +1339,11 @@ class ModernWowSetupTool(WowSetupTool): self._discord_broadcast_mask(), ) else: - source_dll = os.path.join(payload_weirdu, dll_name) - if os.path.exists(source_dll): - shutil.copy2(source_dll, target) + self._install_verified_bundled_file( + f"Payload/WeirdUtils/{dll_name}", + os.path.join(target, dll_name), + dll_name, + ) dlls_text_lines.append(dll_name)