From ddb18abc214646be524b4cf739906f2d4c8ac565 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois?= Date: Thu, 7 May 2026 10:03:30 +0200 Subject: [PATCH] bins.py: host-only Python/Lua in sandboxed bundles _which() probes host dirs only in Flatpak (/run/host/usr/bin) and AppImage (/usr/bin); apply_host_libs prepares env for host subprocesses (prepend host libs in Flatpak, strip $APPDIR pollution + PYTHONHOME in AppImage); user override resolved via _which() for bare names. --- src/testium/interpreter/utils/bins.py | 137 ++++++++++++++++++- src/testium/interpreter/utils/lua_process.py | 2 + src/testium/interpreter/utils/py_process.py | 4 + 3 files changed, 137 insertions(+), 6 deletions(-) diff --git a/src/testium/interpreter/utils/bins.py b/src/testium/interpreter/utils/bins.py index 710dd30..270be5e 100644 --- a/src/testium/interpreter/utils/bins.py +++ b/src/testium/interpreter/utils/bins.py @@ -17,7 +17,7 @@ Public API ``reset()`` : clear the cache (mostly useful for tests) """ -import shutil +import os import subprocess import api.testium as tm @@ -30,10 +30,126 @@ from runtime.tum_except import ETUMRuntimeError _PYTHON_CANDIDATES = ["python3", "python"] _LUA_CANDIDATES = ["lua", "lua5.5", "lua5.4", "lua5.3", "lua5.2", "lua5.1"] +# When running inside a Flatpak, --filesystem=host-os mounts the host at +# /run/host (read-only). Binaries and libraries from the host are not on the +# sandbox PATH/LD_LIBRARY_PATH, so we probe and inject them explicitly. +_FLATPAK_HOST_DIRS = [ + "/run/host/usr/local/bin", + "/run/host/usr/bin", + "/run/host/bin", +] +_FLATPAK_HOST_LIB_DIRS = [ + "/run/host/usr/lib", + "/run/host/usr/lib64", + "/run/host/usr/local/lib", +] + +# Inside an AppImage, AppRun prepends $APPDIR/usr/bin to PATH and exports a +# bundle-local PYTHONHOME / PYTHONPATH / LD_LIBRARY_PATH. We want py_func and +# lua_func to run under the *host* interpreter (not the bundled one), so we +# probe standard host bin dirs directly and scrub APPDIR-prefixed entries from +# the env passed to host subprocesses. +_APPIMAGE_HOST_DIRS = [ + "/usr/local/bin", + "/usr/bin", + "/bin", +] + + +def _in_flatpak(): + return os.path.isfile("/.flatpak-info") + + +def _in_appimage(): + return "APPIMAGE" in os.environ + + +def apply_host_lua_paths(env): + """Prepend host Lua module dirs to LUA_PATH / LUA_CPATH (Flatpak only). + + Must be called after user-defined lua_env overrides are applied, so host + paths are always first regardless of user config. User-defined paths remain + in the variable but after the host ones. + """ + if not _in_flatpak(): + return + _LUA_VERSIONS = ["5.5", "5.4", "5.3", "5.2", "5.1"] + _HOST = "/run/host/usr" + cpath_dirs, lpath_dirs = [], [] + for v in _LUA_VERSIONS: + for base in [f"{_HOST}/lib/lua/{v}", + f"{_HOST}/lib64/lua/{v}", + f"{_HOST}/lib/x86_64-linux-gnu/lua/{v}"]: + cpath_dirs.append(f"{base}/?.so") + lpath_dirs.append(f"{_HOST}/share/lua/{v}/?.lua") + lpath_dirs.append(f"{_HOST}/share/lua/{v}/?/init.lua") + sep = ";" + host_cpath = sep.join(cpath_dirs) + host_lpath = sep.join(lpath_dirs) + # ;; keeps Lua's compiled-in defaults at the end as last resort + env["LUA_CPATH"] = host_cpath + sep + env.get("LUA_CPATH", ";;") + env["LUA_PATH"] = host_lpath + sep + env.get("LUA_PATH", ";;") + + +def apply_host_libs(env): + """Prepare *env* for launching a host binary from inside our bundle. + + - Flatpak: prepend host library dirs to LD_LIBRARY_PATH so the dynamic + linker can find host .so files mounted under /run/host. + - AppImage: strip $APPDIR-prefixed entries from LD_LIBRARY_PATH and + PYTHONPATH and drop PYTHONHOME, so the host interpreter doesn't try + to load the bundled (incompatible) Python lib/site-packages. + - Otherwise: no-op. + """ + if _in_flatpak(): + dirs = ":".join(d for d in _FLATPAK_HOST_LIB_DIRS if os.path.isdir(d)) + if dirs: + existing = env.get("LD_LIBRARY_PATH", "") + env["LD_LIBRARY_PATH"] = dirs + (":" + existing if existing else "") + return + if _in_appimage(): + appdir = os.environ.get("APPDIR", "") + if appdir: + for var, sep in (("LD_LIBRARY_PATH", ":"), + ("PYTHONPATH", os.pathsep), + ("PATH", os.pathsep)): + cur = env.get(var, "") + if not cur: + continue + cleaned = sep.join( + p for p in cur.split(sep) + if p and not p.startswith(appdir) + ) + if cleaned: + env[var] = cleaned + else: + env.pop(var, None) + env.pop("PYTHONHOME", None) + def _which(name): - func = sys_app_path_win if tm.OS() == "Windows" else sys_app_path_lin - return func(name) + if tm.OS() == "Windows": + return sys_app_path_win(name) + if _in_flatpak(): + for d in _FLATPAK_HOST_DIRS: + p = os.path.join(d, name) + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return "" + if _in_appimage(): + for d in _APPIMAGE_HOST_DIRS: + p = os.path.join(d, name) + if os.path.isfile(p) and os.access(p, os.X_OK): + return p + return "" + return sys_app_path_lin(name) + + +def _probe_env(): + """Subprocess env for probing host binaries (adds host libs in Flatpak).""" + env = os.environ.copy() + apply_host_libs(env) + return env def _python_version(path): @@ -41,7 +157,7 @@ def _python_version(path): try: r = subprocess.run( cmd, capture_output=True, text=True, - encoding=tm.sys_encoding(), timeout=10, + encoding=tm.sys_encoding(), timeout=10, env=_probe_env(), ) except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired): return None @@ -60,6 +176,7 @@ def _lua_version(path): try: r = subprocess.run( [path, "-v"], capture_output=True, text=True, timeout=10, + env=_probe_env(), ) except (FileNotFoundError, PermissionError, subprocess.TimeoutExpired): return None @@ -97,8 +214,16 @@ def _resolve(name): path = "" if override: - if shutil.which(override) and validator(override): - path = override + # Absolute path: accept as-is (user knows exactly what they want). + # Bare name: resolve via _which() so the override stays host-only in + # Flatpak/AppImage instead of silently picking the bundled interpreter. + if os.path.isabs(override): + resolved = override if (os.path.isfile(override) + and os.access(override, os.X_OK)) else "" + else: + resolved = _which(override) + if resolved and validator(resolved): + path = resolved else: tm.print_warn( f"Configured {display} interpreter '{override}' is not usable; " diff --git a/src/testium/interpreter/utils/lua_process.py b/src/testium/interpreter/utils/lua_process.py index 9aa1ece..3898779 100644 --- a/src/testium/interpreter/utils/lua_process.py +++ b/src/testium/interpreter/utils/lua_process.py @@ -60,6 +60,7 @@ class LuaProcessBase: lua_env = tm.gd("lua_env", {}) env = os.environ.copy() + bins.apply_host_libs(env) if not isinstance(lua_env, dict): raise ETUMRuntimeError(f"The 'lua_env' global value should be a dictionary. But it is '{lua_env}'.") @@ -70,6 +71,7 @@ class LuaProcessBase: env[k] = e else: env[k] = e + ";" + env.get(k, "") + bins.apply_host_lua_paths(env) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.bind(("localhost", 0)) diff --git a/src/testium/interpreter/utils/py_process.py b/src/testium/interpreter/utils/py_process.py index 595c1f5..8563879 100644 --- a/src/testium/interpreter/utils/py_process.py +++ b/src/testium/interpreter/utils/py_process.py @@ -42,6 +42,10 @@ class PyProcessBase: raise ETUMRuntimeError(f"The 'py_env' global value should be a dictionary. But it is '{py_env}'.") env = os.environ.copy() + bins.apply_host_libs(env) + # PYTHONUSERBASE is set by the Flatpak runtime to isolate sandbox + # user packages; remove it so the host Python finds ~/.local packages. + env.pop("PYTHONUSERBASE", None) for k, v in self.CUST_ENV.items(): e = py_env.get(k, "") if e != "":