Files
wehub-resource-sync 3e779be6f3
CI / lint (push) Failing after 13m4s
CI / test (3.11, ubuntu-latest) (push) Failing after 2m4s
CI / test (3.13, ubuntu-latest) (push) Successful in 13m30s
CI / test (3.14, ubuntu-latest) (push) Successful in 17m21s
CI / test (3.12, ubuntu-latest) (push) Successful in 17m55s
CI / discover-apps-ps (push) Successful in 1m56s
CI / test (3.9, ubuntu-latest) (push) Successful in 13m17s
CI / test (3.10, ubuntu-latest) (push) Successful in 26m21s
CI / audit (push) Successful in 13m38s
Deploy site / deploy (push) Has been cancelled
CI / test (3.14, ubuntu-24.04-arm) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:37 +08:00

252 lines
9.6 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for the AppImage clean-host-env helper (#357, #363; 0.6.0 item A).
The pre-Thin (fat) AppImage prepended ``${APPDIR}/usr/bin`` to PATH and
``${APPDIR}/usr/lib`` to LD_LIBRARY_PATH, which shadowed (#357) or poisoned
(#363) a host that already had a working podman. 0.6.0 item A drops the
bundled podman stack entirely, so nothing in ``${APPDIR}/usr/bin`` shadows
the host container runtime anymore -- ``host_path()`` /
``resolve_backend_bin()`` were removed.
What survives in Thin: the bundled FreeRDP / Python / Qt still need
``${APPDIR}/usr/lib`` on the AppImage's ``LD_LIBRARY_PATH`` (the entrypoint
prepends it), and when the host container runtime spawns host helpers
(``systemd-run`` / ``netavark`` / ``aardvark-dns``) they must NOT inherit
that and load the bundled ``libcrypto`` / ``libssl`` -- the #363 root cause.
:func:`host_env` returns an ``os.environ`` copy with ``${APPDIR}`` entries
stripped, so callers pass it as ``env=`` to ``subprocess`` and the spawned
host runtime + its host helpers load HOST libs. Outside an AppImage every
function here is a strict no-op.
"""
import os
from unittest.mock import patch
from winpodx.backend import _hostenv
# --- in_appimage ---------------------------------------------------------
def test_in_appimage_false_when_appdir_unset():
with patch.dict(os.environ, {}, clear=True):
assert _hostenv.in_appimage() is False
def test_in_appimage_false_when_appdir_empty():
with patch.dict(os.environ, {"APPDIR": ""}, clear=True):
assert _hostenv.in_appimage() is False
def test_in_appimage_true_when_appdir_set():
with patch.dict(os.environ, {"APPDIR": "/tmp/x.AppDir"}, clear=True):
assert _hostenv.in_appimage() is True
# --- host_env: no-op guarantee outside AppImage --------------------------
def test_host_env_none_when_appdir_unset():
"""The safety guarantee for the ~99% non-AppImage installs: host_env()
returns None so callers pass ``env=None`` and subprocess inherits the
current environment unchanged."""
env = {"PATH": "/usr/local/bin:/usr/bin:/bin", "LD_LIBRARY_PATH": "/host/lib"}
with patch.dict(os.environ, env, clear=True):
assert _hostenv.host_env() is None
# --- host_env: strip APPDIR inside an AppImage ---------------------------
def test_host_env_strips_appdir_bin_from_path():
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/opt/app.AppDir/usr/bin:/usr/local/bin:/usr/bin:/bin",
"LD_LIBRARY_PATH": "/opt/app.AppDir/usr/lib:/host/lib",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["PATH"] == "/usr/local/bin:/usr/bin:/bin"
assert "/opt/app.AppDir" not in result["PATH"]
def test_host_env_strips_appdir_lib_from_ld_library_path():
"""The #363 root-cause fix that survives Thin: bundled FreeRDP / Python
/ Qt still need ``${APPDIR}/usr/lib`` on the AppImage's own
LD_LIBRARY_PATH, but the host container runtime + the host helpers it
spawns (``systemd-run`` / ``netavark`` / ``aardvark-dns``) must NOT
inherit it and must load HOST libcrypto / libssl."""
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/opt/app.AppDir/usr/bin:/usr/bin",
"LD_LIBRARY_PATH": "/opt/app.AppDir/usr/lib:/host/lib:/other/lib",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["LD_LIBRARY_PATH"] == "/host/lib:/other/lib"
assert "/opt/app.AppDir" not in result["LD_LIBRARY_PATH"]
def test_host_env_drops_ld_library_path_when_it_empties():
"""An empty LD_LIBRARY_PATH is NOT equivalent to unset -- the dynamic
linker treats "" as the current directory. When stripping APPDIR empties
it, the key must be dropped so the host linker uses its default search
path (the #363 fix: load HOST libcrypto, not the bundled OPENSSL_3.4.0)."""
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/opt/app.AppDir/usr/bin:/usr/bin",
"LD_LIBRARY_PATH": "/opt/app.AppDir/usr/lib",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert "LD_LIBRARY_PATH" not in result
def test_host_env_strips_any_appdir_rooted_entry():
"""Strip *any* entry under ${APPDIR}, not just the two exact dirs the
entrypoint prepends -- so a future entrypoint adding e.g.
${APPDIR}/usr/lib64 is still neutralised."""
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/opt/app.AppDir/usr/bin:/opt/app.AppDir/usr/sbin:/usr/bin",
"LD_LIBRARY_PATH": "/opt/app.AppDir/usr/lib:/opt/app.AppDir/usr/lib64:/host/lib",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["PATH"] == "/usr/bin"
assert result["LD_LIBRARY_PATH"] == "/host/lib"
def test_host_env_preserves_other_variables():
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/opt/app.AppDir/usr/bin:/usr/bin",
"HOME": "/home/u",
"XDG_RUNTIME_DIR": "/run/user/1000",
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/run/user/1000/bus",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["HOME"] == "/home/u"
assert result["XDG_RUNTIME_DIR"] == "/run/user/1000"
assert result["DBUS_SESSION_BUS_ADDRESS"] == "unix:path=/run/user/1000/bus"
def test_host_env_handles_appdir_with_trailing_slash():
env = {
"APPDIR": "/opt/app.AppDir/",
"PATH": "/opt/app.AppDir/usr/bin:/usr/bin",
"LD_LIBRARY_PATH": "/opt/app.AppDir/usr/lib:/host/lib",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["PATH"] == "/usr/bin"
assert result["LD_LIBRARY_PATH"] == "/host/lib"
def test_host_env_leaves_path_alone_when_no_appdir_entries():
"""An AppImage whose entrypoint did not prepend (lean build) -- nothing
to strip, but still not None inside the AppImage."""
env = {
"APPDIR": "/opt/app.AppDir",
"PATH": "/usr/local/bin:/usr/bin",
}
with patch.dict(os.environ, env, clear=True):
result = _hostenv.host_env()
assert result is not None
assert result["PATH"] == "/usr/local/bin:/usr/bin"
assert "LD_LIBRARY_PATH" not in result
# --- removed surface: resolve_backend_bin / host_path are gone -----------
def test_resolve_backend_bin_is_gone():
"""Thin AppImage dropped the host-first bundled-shadow dance because
nothing in ${APPDIR}/usr/bin shadows host container binaries anymore.
Standard PATH resolution finds the host podman / docker directly."""
assert not hasattr(_hostenv, "resolve_backend_bin")
def test_host_path_is_gone():
"""``host_path()`` only existed to support ``resolve_backend_bin``."""
assert not hasattr(_hostenv, "host_path")
# --- call-site wiring: env reaches the subprocess ------------------------
def test_podman_container_state_passes_host_env_inside_appimage():
"""The podman ``ps`` probe (and by extension is_running / is_paused)
must run under the clean host env inside an AppImage so the host runtime
+ the host helpers it spawns load HOST libs (the #363 mitigation that
survives Thin)."""
from winpodx.backend.podman import PodmanBackend
from winpodx.core.config import Config
cfg = Config()
cfg.pod.container_name = "winpodx-windows"
backend = PodmanBackend(cfg)
sentinel_env = {"PATH": "/usr/bin"}
with patch("winpodx.backend.podman.host_env", return_value=sentinel_env):
with patch("winpodx.backend.podman.subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = "running\n"
mock_run.return_value.stderr = ""
backend.is_running()
_, kwargs = mock_run.call_args
assert kwargs["env"] is sentinel_env
cmd = mock_run.call_args[0][0]
# Thin: bare "podman" argv0 -- no resolve_backend_bin host-first dance.
assert cmd[0] == "podman"
def test_podman_container_state_passes_none_env_outside_appimage():
"""Outside an AppImage the same probe must pass ``env=None`` (inherit)
and the bare ``podman`` binary -- byte-for-byte unchanged behaviour."""
from winpodx.backend.podman import PodmanBackend
from winpodx.core.config import Config
cfg = Config()
backend = PodmanBackend(cfg)
with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True):
with patch("winpodx.backend.podman.subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = "running\n"
mock_run.return_value.stderr = ""
backend.is_running()
_, kwargs = mock_run.call_args
assert kwargs["env"] is None
cmd = mock_run.call_args[0][0]
assert cmd[0] == "podman"
def test_docker_container_state_uses_bare_docker_argv0():
"""Same Thin invariant on the docker backend: bare ``docker`` argv0,
no host-first resolve."""
from winpodx.backend.docker import DockerBackend
from winpodx.core.config import Config
cfg = Config()
cfg.pod.container_name = "winpodx-windows"
backend = DockerBackend(cfg)
with patch.dict(os.environ, {"PATH": "/usr/bin"}, clear=True):
with patch("winpodx.backend.docker.subprocess.run") as mock_run:
mock_run.return_value.returncode = 0
mock_run.return_value.stdout = "running\n"
mock_run.return_value.stderr = ""
backend.is_running()
cmd = mock_run.call_args[0][0]
assert cmd[0] == "docker"