From d041363779ba4a5ff2c99609960b2edfbe44e373 Mon Sep 17 00:00:00 2001 From: Dusk-92 Date: Thu, 27 Aug 2026 17:17:13 +0200 Subject: [PATCH] fix: harden DXVK ownership and local builds --- Build.bat | 40 ++++++++++- setup_tool.py | 159 ++++++++++++++++++++++++++++++++++--------- tests/test_safety.py | 77 +++++++++++++++++++++ 3 files changed, 241 insertions(+), 35 deletions(-) diff --git a/Build.bat b/Build.bat index 0b3b279..dfc29fc 100644 --- a/Build.bat +++ b/Build.bat @@ -1 +1,39 @@ -pyinstaller --noconsole --onefile --icon=PurpleWowLogo.ico --add-data "PurpleWowLogo.ico;." --add-data "Payload;Payload" --add-data "vanilla-tweaks.exe;." --name "WoW_Modernization_Tool" setup_tool_dynamic.py \ No newline at end of file +@echo off +setlocal +cd /d "%~dp0" + +where py >nul 2>nul +if errorlevel 1 ( + echo ERROR: Python launcher ^(py.exe^) was not found. + exit /b 1 +) + +py -3.11 -c "import sys; a=sys.version_info[:3]; sys.exit(0 if a==(3,11,9) else f'ERROR: Python 3.11.9 is required; found {a[0]}.{a[1]}.{a[2]}')" +if errorlevel 1 exit /b 1 + +py -3.11 -m pip install --disable-pip-version-check -r requirements-build.txt +if errorlevel 1 exit /b 1 + +py -3.11 -m py_compile setup_tool.py setup_tool_dynamic.py remote_packages.py tests/test_safety.py +if errorlevel 1 exit /b 1 + +py -3.11 -m unittest discover -s tests -v +if errorlevel 1 exit /b 1 + +py -3.11 -m PyInstaller ^ + --noconsole ^ + --onefile ^ + --icon=PurpleWowLogo.ico ^ + --add-data "PurpleWowLogo.ico;." ^ + --add-data "Payload;Payload" ^ + --add-data "vanilla-tweaks.exe;." ^ + --name "WoW_Modernization_Tool" ^ + setup_tool_dynamic.py +if errorlevel 1 exit /b 1 + +if not exist "dist\WoW_Modernization_Tool.exe" ( + echo ERROR: Build output is missing. + exit /b 1 +) + +echo Build complete: dist\WoW_Modernization_Tool.exe diff --git a/setup_tool.py b/setup_tool.py index c99ae74..79f8fcd 100644 --- a/setup_tool.py +++ b/setup_tool.py @@ -1236,41 +1236,68 @@ class WowSetupTool: return True def clean_unselected_files(self, target): - """Removes managed files/folders if they were explicitly unselected by the user.""" - - # 1. Clean AutoLogin files if unselected + """Remove explicitly unselected tool-managed files without hiding failures.""" + + def remove_managed_file(path, label): + if not os.path.lexists(path): + return + try: + os.remove(path) + except OSError as exc: + raise RuntimeError( + f"Could not remove {label}. Close WoW and any program using the file, then try again." + ) from exc + + def remove_managed_tree(path, label): + if not os.path.lexists(path): + return + try: + shutil.rmtree(path) + except OSError as exc: + raise RuntimeError( + f"Could not remove {label}. Close WoW and any program using the folder, then try again." + ) from exc + + # 1. Clean AutoLogin files if unselected. if not self.install_autologin.get(): glue_dir = os.path.join(target, "Data", "Interface", "GlueXML") for file_name in ["AutoLogin.lua", "AutoLogin.xml", "GlueXML.toc"]: - file_path = os.path.join(glue_dir, file_name) - if os.path.exists(file_path): - try: os.remove(file_path) - except: pass - if os.path.exists(glue_dir) and not os.listdir(glue_dir): - try: os.rmdir(glue_dir) - except: pass + remove_managed_file( + os.path.join(glue_dir, file_name), + f"managed AutoLogin file {file_name}", + ) + if os.path.isdir(glue_dir): + try: + if not os.listdir(glue_dir): + os.rmdir(glue_dir) + except OSError: + # Leaving an empty directory behind is harmless; unlike a + # stale DLL/AddOn it cannot change the running client. + pass - # 2. Clean unselected Core Plugins and their dependent AddOns + # 2. Clean unselected Core Plugins and their dependent AddOns. for dll_name, var in self.core_plugins.items(): if not var.get(): - dll_path = os.path.join(target, dll_name) - if os.path.exists(dll_path): - try: os.remove(dll_path) - except: pass - + remove_managed_file( + os.path.join(target, dll_name), + f"managed plugin {dll_name}", + ) + addon_folder = self.addon_dependencies.get(dll_name) if addon_folder: - addon_path = os.path.join(target, "Interface", "AddOns", addon_folder) - if os.path.exists(addon_path): - shutil.rmtree(addon_path, ignore_errors=True) + remove_managed_tree( + os.path.join(target, "Interface", "AddOns", addon_folder), + f"managed addon {addon_folder}", + ) - # 3. Clean unselected Optional plugins + # 3. Clean unselected Optional plugins. for dll_name, var in self.optional_plugins.items(): if not var.get(): - dll_path = os.path.join(target, dll_name) - if os.path.exists(dll_path): - try: os.remove(dll_path) - except: pass + remove_managed_file( + os.path.join(target, dll_name), + f"managed plugin {dll_name}", + ) + def configure_script_memory(self, target): """Optionally set AddOn Script Memory to 0 (unlimited) in WTF/Config.wtf.""" @@ -1996,23 +2023,87 @@ oLink.Save if os.path.exists(source_file): shutil.copy2(source_file, target) + def _previous_settings_used_dxvk(self, target): + """Use the previous saved selection as legacy ownership proof.""" + settings_path = self._settings_path(target) + if not os.path.isfile(settings_path): + return False + try: + with open(settings_path, "r", encoding="utf-8") as handle: + saved = json.load(handle) + except (OSError, json.JSONDecodeError, ValueError, TypeError): + return False + return ( + isinstance(saved, dict) + and saved.get("rendering_mode") == "dxvk" + ) + def configure_dxvk(self, target): + """Manage DXVK transactionally and never delete unowned renderer files.""" payload_dir = os.path.join(get_base_path(), "Payload") + d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll") + conf_src = os.path.join(payload_dir, "dxvk.conf") d3d9_target = os.path.join(target, "d3d9.dll") conf_target = os.path.join(target, "dxvk.conf") + managed_id = "renderer_dxvk" + + managed_files = remote_packages._load_managed_manifest(target, managed_id) if self.rendering_mode.get() == "dxvk": - d3d9_src = os.path.join(payload_dir, "DXVK_Standard", "d3d9.dll") - conf_src = os.path.join(payload_dir, "dxvk.conf") + missing = [ + path for path in (d3d9_src, conf_src) + if not os.path.isfile(path) + ] + if missing: + raise FileNotFoundError( + "Bundled DXVK files are incomplete: " + + ", ".join(os.path.basename(path) for path in missing) + ) + + # Migrate installations created before renderer ownership manifests + # existed. A saved DXVK selection is explicit evidence that these + # two paths were managed by this tool on the previous Apply. + if not managed_files and self._previous_settings_used_dxvk(target): + for path in (conf_target, d3d9_target): + if os.path.lexists(path): + try: + os.remove(path) + except OSError as exc: + raise RuntimeError( + f"Could not migrate the previous managed DXVK file {os.path.basename(path)}. " + "Close WoW and any program using it, then try again." + ) from exc + + remote_packages._install_managed_files_transactional( + target, + managed_id, + [ + (d3d9_src, "d3d9.dll"), + (conf_src, "dxvk.conf"), + ], + revision="1", + ) + return + + # New managed installs restore any files that existed before DXVK was + # enabled. This means a user's own d3d9.dll/dxvk.conf is never lost. + if managed_files: + remote_packages.remove_managed_mod(target, managed_id) + return + + # Legacy cleanup is allowed only with explicit previous settings proof. + # Unknown/manual renderer files are deliberately preserved. + if self._previous_settings_used_dxvk(target): + for path in (conf_target, d3d9_target): + if os.path.lexists(path): + try: + os.remove(path) + except OSError as exc: + raise RuntimeError( + f"Could not remove the previous managed DXVK file {os.path.basename(path)}. " + "Close WoW and any program using it, then try again." + ) from exc - if os.path.exists(d3d9_src): - shutil.copy2(d3d9_src, d3d9_target) - if os.path.exists(conf_src): - shutil.copy2(conf_src, conf_target) - else: - for path in (d3d9_target, conf_target): - if os.path.exists(path): - os.remove(path) def _managed_dll_entries(self): """Return every dlls.txt entry owned by this tool.""" diff --git a/tests/test_safety.py b/tests/test_safety.py index 2dd71b6..dff23c5 100644 --- a/tests/test_safety.py +++ b/tests/test_safety.py @@ -155,6 +155,83 @@ class WdbBlockerTests(unittest.TestCase): self.assertEqual(handle.read(), b"foreign") +class DxvkOwnershipTests(unittest.TestCase): + def make_tool(self, mode): + tool = WowSetupTool.__new__(WowSetupTool) + tool.rendering_mode = FakeVar(mode) + return tool + + def make_payload(self, root): + payload = os.path.join(root, "Payload") + dxvk = os.path.join(payload, "DXVK_Standard") + os.makedirs(dxvk) + with open(os.path.join(dxvk, "d3d9.dll"), "wb") as handle: + handle.write(b"bundled-dxvk") + with open(os.path.join(payload, "dxvk.conf"), "wb") as handle: + handle.write(b"bundled-conf") + + def test_enable_then_disable_restores_preexisting_renderer_files(self): + tool = self.make_tool("dxvk") + with tempfile.TemporaryDirectory() as bundle, tempfile.TemporaryDirectory() as target: + self.make_payload(bundle) + d3d9 = os.path.join(target, "d3d9.dll") + conf = os.path.join(target, "dxvk.conf") + with open(d3d9, "wb") as handle: + handle.write(b"user-d3d9") + with open(conf, "wb") as handle: + handle.write(b"user-conf") + + with mock.patch("setup_tool.get_base_path", return_value=bundle): + tool.configure_dxvk(target) + with open(d3d9, "rb") as handle: + self.assertEqual(handle.read(), b"bundled-dxvk") + with open(conf, "rb") as handle: + self.assertEqual(handle.read(), b"bundled-conf") + + tool.rendering_mode.set("directx9") + tool.configure_dxvk(target) + + with open(d3d9, "rb") as handle: + self.assertEqual(handle.read(), b"user-d3d9") + with open(conf, "rb") as handle: + self.assertEqual(handle.read(), b"user-conf") + + def test_directx9_preserves_unowned_renderer_files(self): + tool = self.make_tool("directx9") + with tempfile.TemporaryDirectory() as target: + d3d9 = os.path.join(target, "d3d9.dll") + conf = os.path.join(target, "dxvk.conf") + with open(d3d9, "wb") as handle: + handle.write(b"manual-d3d9") + with open(conf, "wb") as handle: + handle.write(b"manual-conf") + + tool.configure_dxvk(target) + + with open(d3d9, "rb") as handle: + self.assertEqual(handle.read(), b"manual-d3d9") + with open(conf, "rb") as handle: + self.assertEqual(handle.read(), b"manual-conf") + + +class StrictCleanupTests(unittest.TestCase): + def test_locked_managed_plugin_removal_is_not_silently_ignored(self): + tool = WowSetupTool.__new__(WowSetupTool) + tool.install_autologin = FakeVar(True) + tool.core_plugins = {"CorePlugin.dll": FakeVar(False)} + tool.optional_plugins = {} + tool.addon_dependencies = {} + + with tempfile.TemporaryDirectory() as target: + dll_path = os.path.join(target, "CorePlugin.dll") + with open(dll_path, "wb") as handle: + handle.write(b"dll") + + with mock.patch("setup_tool.os.remove", side_effect=PermissionError("locked")): + with self.assertRaises(RuntimeError): + tool.clean_unselected_files(target) + + class ManagedPackageTests(unittest.TestCase): def test_mpq_revision_and_magic_are_both_checked(self): with tempfile.TemporaryDirectory() as root: