From e71198aa38aa7738558027541d070a328db05bd7 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 28 Mar 2024 19:32:29 -0700 Subject: [PATCH 1/2] Support copying files for immutable distributions - Related to https://github.com/Open-Wine-Components/umu-launcher/pull/73 --- umu/umu_consts.py | 7 ++++ umu/umu_util.py | 87 +++++++++++++++++++++++++++++++---------------- 2 files changed, 65 insertions(+), 29 deletions(-) diff --git a/umu/umu_consts.py b/umu/umu_consts.py index ee316529..181ea979 100644 --- a/umu/umu_consts.py +++ b/umu/umu_consts.py @@ -35,3 +35,10 @@ class Color(Enum): "getcompatpath", "getnativepath", } + + +class MODE(Enum): + """Represent the permission to apply to a file.""" + + USER_RW = 0o0644 + USER_RWX = 0o0755 diff --git a/umu/umu_util.py b/umu/umu_util.py index 930f28c1..5833a932 100644 --- a/umu/umu_util.py +++ b/umu/umu_util.py @@ -1,11 +1,11 @@ from tarfile import open as tar_open, TarInfo from os import environ -from umu_consts import CONFIG, STEAM_COMPAT, UMU_LOCAL +from umu_consts import CONFIG, STEAM_COMPAT, UMU_LOCAL, MODE from typing import Any, Dict, List, Callable from json import load, dump from umu_log import log from pathlib import Path -from shutil import rmtree, move, copy, copytree +from shutil import rmtree, move, copy from umu_plugins import enable_zenity from urllib.request import urlopen from ssl import create_default_context @@ -154,8 +154,7 @@ def _install_umu( ~/.local/share/umu, ~/.local/share/Steam/compatibilitytools.d The tools that will be copied are: - SteamRT, Pressure Vessel, umu-launcher, umu Launcher files, Reaper - and umu_version.json + umu-launcher, umu Launcher files, reaper and umu_version.json """ log.debug("New install detected") log.console("Setting up Unified Launcher for Windows Games on Linux ...") @@ -164,17 +163,18 @@ def _install_umu( # Config log.console(f"Copied {CONFIG} -> {local}") - copy(root.joinpath(CONFIG), local.joinpath(CONFIG)) + _copy(root.joinpath(CONFIG), local.joinpath(CONFIG)) # Reaper log.console(f"Copied reaper -> {local}") - copy(root.joinpath("reaper"), local.joinpath("reaper")) + _copy(root.joinpath("reaper"), local.joinpath("reaper"), MODE.USER_RWX) # Launcher files for file in root.glob("*.py"): - if not file.name.startswith("umu_test"): + if not file.name.startswith(("umu_test", "umu_run")): log.console(f"Copied {file} -> {local}") - copy(file, local.joinpath(file.name)) + _copy(file, local.joinpath(file.name)) + _copy(root.joinpath("umu_run.py"), local.joinpath("umu_run.py"), MODE.USER_RWX) local.joinpath("umu-run").symlink_to("umu_run.py") @@ -184,11 +184,9 @@ def _install_umu( # Remove existing files if they exist -- this is a clean install. if steam_compat.joinpath("umu-launcher").is_dir(): rmtree(steam_compat.joinpath("umu-launcher").as_posix()) - copytree( + _copytree( root.joinpath("umu-launcher"), steam_compat.joinpath("umu-launcher"), - dirs_exist_ok=True, - symlinks=True, ) steam_compat.joinpath("umu-launcher", "umu-run").symlink_to( "../../../umu/umu_run.py" @@ -232,7 +230,7 @@ def _update_umu( # Directory is absent if not local.joinpath("reaper").is_file(): log.warning("Reaper not found") - copy(root.joinpath("reaper"), local.joinpath("reaper")) + _copy(root.joinpath("reaper"), local.joinpath("reaper"), MODE.USER_RWX) log.console(f"Restored {key} to {val}") # Update @@ -240,7 +238,7 @@ def _update_umu( log.console(f"Updating {key} to {val}") local.joinpath("reaper").unlink(missing_ok=True) - copy(root.joinpath("reaper"), local.joinpath("reaper")) + _copy(root.joinpath("reaper"), local.joinpath("reaper"), MODE.USER_RWX) json_local["umu"]["versions"]["reaper"] = val elif key == "runtime_platform": @@ -286,9 +284,14 @@ def _update_umu( # Python files for file in root.glob("*.py"): - if not file.name.startswith("umu_test"): + if not file.name.startswith(("umu_test", "umu_run")): local.joinpath(file.name).unlink(missing_ok=True) - copy(file, local.joinpath(file.name)) + _copy(file, local.joinpath(file.name)) + _copy( + root.joinpath("umu_run.py"), + local.joinpath("umu_run.py"), + MODE.USER_RWX, + ) # Symlink local.joinpath("umu-run").unlink(missing_ok=True) @@ -298,14 +301,23 @@ def _update_umu( continue # Check for missing files - for file in root.glob("*.py"): - if ( - not file.name.startswith("umu_test") - and not local.joinpath(file.name).is_file() - ): - is_missing = True - log.warning("Missing %s", file.name) - copy(file, local.joinpath(file.name)) + for file in [ + file + for file in root.glob("*.py") + if not file.name.startswith(("umu_test", "umu_run")) + and not local.joinpath(file.name).is_file() + ]: + is_missing = True + log.warning("Missing %s", file.name) + _copy(file, local.joinpath(file.name)) + + if not local.joinpath("umu_run.py"): + log.warning("Missing %s", file.name) + _copy( + root.joinpath("umu_run.py"), + local.joinpath("umu_run.py"), + MODE.USER_RWX, + ) if is_missing: log.console(f"Restored {key} to {val}") @@ -319,11 +331,9 @@ def _update_umu( if not steam_compat.joinpath("umu-launcher").is_dir(): log.warning("umu-launcher not found") - copytree( + _copytree( root.joinpath("umu-launcher"), steam_compat.joinpath("umu-launcher"), - dirs_exist_ok=True, - symlinks=True, ) steam_compat.joinpath("umu-launcher", "umu-run").symlink_to( @@ -335,11 +345,9 @@ def _update_umu( log.console(f"Updating {key} to {val}") rmtree(steam_compat.joinpath("umu-launcher").as_posix()) - copytree( + _copytree( root.joinpath("umu-launcher"), steam_compat.joinpath("umu-launcher"), - dirs_exist_ok=True, - symlinks=True, ) steam_compat.joinpath("umu-launcher", "umu-run").symlink_to( @@ -384,3 +392,24 @@ def _get_json(path: Path, config: str) -> Dict[str, Any]: raise ValueError(err) return json + + +def _copy(src: Path, dst: Path, mode: MODE = MODE.USER_RW) -> None: + dst.parent.mkdir(parents=True, exist_ok=True) + + if src.is_symlink(): + dst.symlink_to(src.readlink()) + return + + copy(src, dst) + dst.chmod(mode.value) + + +def _copytree(src: Path, dest: Path, mode: MODE = MODE.USER_RW) -> None: + for file in src.iterdir(): + if file.is_dir(): + dest_subdir = dest / file.name + dest_subdir.mkdir(parents=True, exist_ok=True) + _copytree(file, dest_subdir) + else: + _copy(file, dest / file.name, mode) From 96785214f9b3803587043359a6f5e71866c8cd00 Mon Sep 17 00:00:00 2001 From: R1kaB3rN <100738684+R1kaB3rN@users.noreply.github.com> Date: Thu, 28 Mar 2024 19:33:23 -0700 Subject: [PATCH 2/2] umu_test: add test when copying read-only files --- umu/umu_test.py | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/umu/umu_test.py b/umu/umu_test.py index 7a083dcf..53e624d4 100644 --- a/umu/umu_test.py +++ b/umu/umu_test.py @@ -14,6 +14,7 @@ from pathlib import Path from shutil import rmtree, copytree, copy from pwd import getpwuid +from umu_consts import MODE class TestGameLauncher(unittest.TestCase): @@ -163,6 +164,106 @@ def tearDown(self): if self.test_local_share.exists(): rmtree(self.test_local_share.as_posix()) + def test_copy(self): + """Test _copy when copying a subset of core files from a system path.""" + # Make files read-only + self.test_user_share.joinpath("reaper").chmod(0o0444) + self.test_user_share.joinpath("umu_run.py").chmod(0o0444) + self.test_user_share.joinpath("umu_consts.py").chmod(0o0444) + # Make umu-launcher files read-only + Path(self.test_user_share, "umu-launcher", "compatibilitytool.vdf").chmod( + 0o0444 + ) + Path(self.test_user_share, "umu-launcher", "toolmanifest.vdf").chmod(0o0444) + # Verify read-only before copying to ~/.local/share/umu + self.assertTrue( + self.test_user_share.joinpath("reaper").stat().st_mode == 33060, + "Expected reaper to be read only", + ) + self.assertTrue( + self.test_user_share.joinpath("umu_run.py").stat().st_mode == 33060, + "Expected umu_run to be read only", + ) + self.assertTrue( + self.test_user_share.joinpath("umu_consts.py").stat().st_mode == 33060, + "Expected umu_consts to be read only", + ) + self.assertTrue( + self.test_user_share.joinpath("umu-launcher", "compatibilitytool.vdf") + .stat() + .st_mode + == 33060, + "Expected compat vdf to be read only", + ) + self.assertTrue( + self.test_user_share.joinpath("umu-launcher", "toolmanifest.vdf") + .stat() + .st_mode + == 33060, + "Expected manifest vdf to be read only", + ) + # Copy from source dir + umu_util._copy( + self.test_user_share.joinpath("reaper"), + self.test_local_share.joinpath("reaper"), + MODE.USER_RWX, + ) + umu_util._copy( + self.test_user_share.joinpath("umu_run.py"), + self.test_local_share.joinpath("umu_run.py"), + MODE.USER_RWX, + ) + umu_util._copy( + self.test_user_share.joinpath("umu_consts.py"), + self.test_local_share.joinpath("umu_consts.py"), + ) + umu_util._copytree( + self.test_user_share.joinpath("umu-launcher"), + self.test_local_share.joinpath("umu-launcher"), + ) + # Test reaper for user rwx + # In particular, it's important umu_run and reaper are executable + # otherwise, the launcher will not work + self.assertTrue( + os.access(self.test_local_share.joinpath("reaper"), os.X_OK), + "Expected execute perm for reaper", + ) + self.assertTrue( + os.access(self.test_local_share.joinpath("reaper"), os.W_OK), + "Expected write perm for reaper", + ) + # Test umu_run.py for user rwx + self.assertTrue( + os.access(self.test_local_share.joinpath("umu_run.py"), os.X_OK), + "Expected execute perm for umu_run", + ) + self.assertTrue( + os.access(self.test_local_share.joinpath("umu_run.py"), os.W_OK), + "Expected write perm for umu_run", + ) + # Test launcher files for user rw + # Just test one of them since the default is rw and if one of them + # fails then that signals the rest will too + self.assertTrue( + os.access(self.test_local_share.joinpath("umu_consts.py"), os.W_OK), + "Expected write perm for launcher file", + ) + # Test umu-launcher + self.assertTrue( + os.access( + self.test_local_share.joinpath("umu-launcher", "compatibilitytool.vdf"), + os.W_OK, + ), + "Expected write perm for launcher file", + ) + self.assertTrue( + os.access( + self.test_local_share.joinpath("umu-launcher", "toolmanifest.vdf"), + os.W_OK, + ), + "Expected write perm for launcher file", + ) + def test_ge_proton(self): """Test check_env when the code name GE-Proton is set for PROTONPATH.