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

1393 lines
55 KiB
Python

# SPDX-License-Identifier: MIT
"""Tests for RDP session management."""
from __future__ import annotations
import pytest
from winpodx.core.config import Config
from winpodx.core.rdp import _auto_kbd_flag, build_rdp_command, linux_to_unc
def test_linux_to_unc_home(monkeypatch, tmp_path):
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: tmp_path))
doc = tmp_path / "Documents" / "test.docx"
doc.parent.mkdir()
doc.touch()
result = linux_to_unc(str(doc))
assert result == "\\\\tsclient\\home\\Documents\\test.docx"
def test_linux_to_unc_outside_home_raises(monkeypatch, tmp_path):
# Paths outside $HOME and any media share must raise.
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: tmp_path / "home"))
monkeypatch.setattr("winpodx.core.rdp._find_media_base", lambda: None)
with pytest.raises(ValueError, match="outside shared locations"):
linux_to_unc("/tmp/test.txt")
def test_linux_to_unc_media_path(monkeypatch, tmp_path):
media = tmp_path / "run_media" / "user"
media.mkdir(parents=True)
usb_file = media / "USB" / "report.docx"
usb_file.parent.mkdir()
usb_file.touch()
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: tmp_path / "home"))
monkeypatch.setattr("winpodx.core.rdp._find_media_base", lambda: media)
result = linux_to_unc(str(usb_file))
assert result == "\\\\tsclient\\media\\USB\\report.docx"
def test_find_media_base_prefers_user_then_persistent_parent(monkeypatch):
# A live media parent is preferred over the placeholder so a USB inserted
# AFTER the session starts shows on refresh. Per-user dir wins; the
# persistent parent is the fallback for the not-mounted-yet-at-launch case.
from winpodx.core import rdp
monkeypatch.setenv("USER", "alice")
present: set[str] = set()
monkeypatch.setattr(rdp.Path, "is_dir", lambda self: str(self) in present)
# Nothing mounted, no media subsystem dir -> None (caller uses placeholder).
assert rdp._find_media_base() is None
# USB inserted while no per-user dir existed yet: the persistent parent
# exists, so we redirect it (USB shows at \\media\alice\<LABEL> on F5).
present.add("/run/media")
assert str(rdp._find_media_base()) == "/run/media"
# Once udisks has made the per-user dir, prefer it (\\media\<LABEL>).
present.add("/run/media/alice")
assert str(rdp._find_media_base()) == "/run/media/alice"
def test_launch_app_remoteapp_without_display_raises(monkeypatch, tmp_path):
# No $DISPLAY -> xfreerdp would die post-detach; launch_app must raise.
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod, "find_freerdp", lambda *a, **k: ("/usr/bin/xfreerdp", "xfreerdp"))
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.core.process.runtime_dir", lambda: tmp_path)
monkeypatch.delenv("DISPLAY", raising=False)
# A real launch has credentials; set them so we reach the display/XWayland
# guard rather than the empty-credential guards (#569).
cfg = Config()
cfg.rdp.user = "TestUser"
cfg.rdp.password = "secret"
with pytest.raises(RuntimeError, match="XWayland"):
rdp_mod.launch_app(cfg, app_executable="notepad.exe")
def test_build_rdp_command_empty_user_raises_clear_error(monkeypatch):
# #569: an empty username made xfreerdp fall back to an interactive prompt
# that died with "Inappropriate ioctl for device" under a GUI launch. Fail
# fast with an actionable message instead.
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod, "find_freerdp", lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"))
cfg = Config()
cfg.rdp.user = ""
with pytest.raises(RuntimeError, match="credentials are not configured"):
rdp_mod.build_rdp_command(cfg)
def test_build_rdp_command_empty_password_and_no_askpass_raises_clear_error(monkeypatch):
# Empty password with no askpass makes xfreerdp prompt interactively, which
# dies under a GUI launch with the same "Inappropriate ioctl for device" /
# ERRCONNECT_CONNECT_CANCELLED error as an empty username.
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod, "find_freerdp", lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"))
cfg = Config()
cfg.rdp.user = "TestUser"
cfg.rdp.password = ""
cfg.rdp.askpass = ""
with pytest.raises(RuntimeError, match="password is not configured"):
rdp_mod.build_rdp_command(cfg)
def test_find_freerdp_returns_tuple_or_none():
from winpodx.core.rdp import find_freerdp
result = find_freerdp()
assert result is None or (isinstance(result, tuple) and len(result) == 2)
def _patch_freerdp_probes(monkeypatch, *, native, flatpak):
"""Stub the native + Flatpak FreeRDP probes independently."""
import winpodx.core.rdp as rdp_mod
rdp_mod._FREERDP_CACHE.clear()
monkeypatch.setattr(
rdp_mod,
"_find_native_freerdp",
lambda: ("/usr/bin/xfreerdp3", "xfreerdp") if native else None,
)
monkeypatch.setattr(
rdp_mod,
"_find_flatpak_freerdp",
lambda: ("flatpak run com.freerdp.FreeRDP", "flatpak") if flatpak else None,
)
def test_find_freerdp_auto_prefers_flatpak_when_both_present(monkeypatch):
# The Flatpak ships a self-contained FreeRDP 3+ (no host package skew) and
# its RAIL multi-display rough edges are handled by cfg.rdp.multimon=span,
# so auto prefers the Flatpak when both are installed.
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=True, flatpak=True)
assert find_freerdp("auto") == ("flatpak run com.freerdp.FreeRDP", "flatpak")
def test_find_freerdp_auto_falls_back_to_native(monkeypatch):
# No Flatpak -> the native client is the fallback under auto.
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=True, flatpak=False)
assert find_freerdp("auto") == ("/usr/bin/xfreerdp3", "xfreerdp")
def test_find_freerdp_auto_uses_flatpak_when_only_flatpak(monkeypatch):
# Only the Flatpak present -> auto uses it.
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=False, flatpak=True)
assert find_freerdp("auto") == ("flatpak run com.freerdp.FreeRDP", "flatpak")
def test_find_freerdp_native_prefers_native(monkeypatch):
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=True, flatpak=True)
assert find_freerdp("native") == ("/usr/bin/xfreerdp3", "xfreerdp")
def test_find_freerdp_native_falls_back_to_flatpak(monkeypatch):
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=False, flatpak=True)
assert find_freerdp("native") == ("flatpak run com.freerdp.FreeRDP", "flatpak")
def test_find_freerdp_flatpak_forced(monkeypatch):
from winpodx.core.rdp import find_freerdp
_patch_freerdp_probes(monkeypatch, native=True, flatpak=True)
assert find_freerdp("flatpak") == ("flatpak run com.freerdp.FreeRDP", "flatpak")
def test_flatpak_invocation_forces_xfreerdp_and_grants_perms():
# The Flatpak default command is the SDL client (no RAIL) -> a RemoteApp
# launch would open the full desktop. We must force xfreerdp and open the
# sandbox holes winpodx's RDP flags need.
from winpodx.core.rdp import _FLATPAK_FREERDP_CMD
assert _FLATPAK_FREERDP_CMD.startswith("flatpak run ")
assert "--command=xfreerdp" in _FLATPAK_FREERDP_CMD # RAIL-capable client
assert _FLATPAK_FREERDP_CMD.endswith(" com.freerdp.FreeRDP") # app id last
for perm in (
"--share=network", # /v: localhost RDP
"--socket=x11", # RAIL + clipboard
"--socket=wayland",
"--socket=pulseaudio", # /sound
"--socket=cups", # /printer
"--filesystem=home", # \\tsclient\home + drive redirect
"--filesystem=/run/media", # \\tsclient\media (USB)
):
assert perm in _FLATPAK_FREERDP_CMD, f"missing sandbox permission: {perm}"
def test_find_existing_session_rejects_non_freerdp_pid(tmp_path, monkeypatch):
# A non-freerdp process with 'winpodx' in its cmdline must not look like a live RDP session.
import shutil
import subprocess
from winpodx.core.rdp import _find_existing_session
monkeypatch.setattr("winpodx.core.rdp.runtime_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.core.process.runtime_dir", lambda: tmp_path)
sleep = shutil.which("sleep")
if sleep is None: # pragma: no cover
pytest.skip("sleep not available")
# Any live process whose argv[0] is not a FreeRDP binary must be rejected
# and its stale .cproc marker reaped (PID-reuse guard).
child = subprocess.Popen([sleep, "30"]) # noqa: S603
try:
pidfile = tmp_path / "notepad.cproc"
pidfile.write_text(str(child.pid))
result = _find_existing_session("notepad")
assert result is None, "must not resurrect a non-freerdp PID"
assert not pidfile.exists(), "stale cproc marker must be cleaned"
finally:
child.kill()
child.wait(timeout=5)
def test_find_existing_session_cleans_dead_pid(tmp_path, monkeypatch):
from winpodx.core.rdp import _find_existing_session
monkeypatch.setattr("winpodx.core.rdp.runtime_dir", lambda: tmp_path)
monkeypatch.setattr("winpodx.core.process.runtime_dir", lambda: tmp_path)
pidfile = tmp_path / "notepad.cproc"
pidfile.write_text("99999999") # almost certainly dead
assert _find_existing_session("notepad") is None
assert not pidfile.exists()
def test_is_freerdp_pid_helper_accepts_freerdp_only():
from winpodx.core import process as proc_mod
assert proc_mod.is_freerdp_pid(99999999) is False
from unittest.mock import patch
class _FakeCmdline:
def __init__(self, content: bytes) -> None:
self._content = content
def read_bytes(self) -> bytes:
return self._content
def fake_path_factory(content: bytes):
return lambda _p: _FakeCmdline(content)
with (
patch("winpodx.core.process.os.kill", return_value=None),
patch(
"winpodx.core.process.Path",
side_effect=fake_path_factory(b"/usr/bin/winpodx\0app\0list\0"),
),
):
assert proc_mod.is_freerdp_pid(12345) is False
with (
patch("winpodx.core.process.os.kill", return_value=None),
patch(
"winpodx.core.process.Path",
side_effect=fake_path_factory(b"/usr/bin/xfreerdp3\0/v:127.0.0.1\0"),
),
):
assert proc_mod.is_freerdp_pid(12345) is True
# build_rdp_command tests
class TestBuildRdpCommand:
@pytest.fixture()
def cfg(self):
c = Config()
c.rdp.ip = "127.0.0.1"
c.rdp.port = 3390
c.rdp.user = "TestUser"
c.rdp.password = "secret"
c.rdp.scale = 100
c.rdp.dpi = 0
c.rdp.extra_flags = ""
c.pod.backend = "manual"
return c
def test_raises_without_freerdp(self, cfg, monkeypatch):
monkeypatch.setattr("winpodx.core.rdp.find_freerdp", lambda *a, **k: None)
with pytest.raises(RuntimeError, match="FreeRDP 3\\+ not found"):
build_rdp_command(cfg)
def test_basic_command_structure(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cmd, password = build_rdp_command(cfg)
assert password == "" # password embedded in /p: flag
assert any(c.startswith("/p:") for c in cmd)
assert "/v:127.0.0.1:3390" in cmd
assert "/u:TestUser" in cmd
assert "/cert:ignore" in cmd
assert "/scale:100" in cmd
assert "/from-stdin:force" not in cmd
def test_remote_ip_uses_tofu(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.ip = "192.168.1.100"
cmd, _ = build_rdp_command(cfg)
assert "/cert:tofu" in cmd
assert "/cert:ignore" not in cmd
def test_app_executable_freerdp3_uses_combined_syntax(self, cfg, monkeypatch):
"""On FreeRDP 3, Win32 RemoteApp must use the combined
``/app:program:X,name:Y[,cmd:Z]`` syntax. FreeRDP 3 parses
``/app:`` as ``<key>:<value>,...`` so bare ``/app:PATH`` is
rejected with "Unexpected keyword" at the path's drive prefix.
Regression test for the smoke failure on Tumbleweed FreeRDP
3.24.1 (2026-05-14)."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 3)
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/app:program:notepad.exe,name:notepad" in cmd
# Separate /app-name: / /app-cmd: flags MUST NOT appear — they
# double up with the combined form's name: / cmd: sub-keys.
assert not any(c.startswith("/app-name:") for c in cmd)
assert not any(c.startswith("/app-cmd:") for c in cmd)
def test_app_executable_freerdp2_uses_separate_flags(self, cfg, monkeypatch):
"""On FreeRDP 2, Win32 RemoteApp must use the separate
``/app:PATH`` + ``/app-name:NAME`` + ``/app-cmd:CMD`` flag
form. FreeRDP 2 parses the combined ``program:X,name:Y,cmd:Z``
string as the literal program path and falls back to the
Microsoft Store handler (#158, reported by @poetman)."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 2)
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/app:notepad.exe" in cmd
assert any(c.startswith("/app-name:") for c in cmd)
# Combined form must NOT appear on FreeRDP 2.
assert not any(c.startswith("/app:program:") for c in cmd)
def test_app_cmd_freerdp3_inlined_into_app_arg(self, cfg, monkeypatch):
"""FreeRDP 3 combined form bundles ``cmd:`` into the same
``/app:`` arg, with comma-to-space sanitisation."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 3)
cmd, _ = build_rdp_command(
cfg,
app_executable="explorer.exe",
default_args="shell:Desktop",
)
assert any(",cmd:shell:Desktop" in c for c in cmd)
assert not any(c.startswith("/app-cmd:") for c in cmd)
def test_app_cmd_freerdp2_uses_separate_flag(self, cfg, monkeypatch):
"""FreeRDP 2 puts ``default_args`` on its own ``/app-cmd:``
flag — commas inside the value are safe because each flag is
its own argv entry."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 2)
cmd, _ = build_rdp_command(
cfg,
app_executable="explorer.exe",
default_args="shell:Desktop",
)
assert "/app-cmd:shell:Desktop" in cmd
def test_file_path_with_space_is_quoted_freerdp3(self, cfg, monkeypatch, tmp_path):
"""#473: a file path containing a space must reach the guest quoted,
or the RAIL command line splits and the app gets 'path not found'."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 3)
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: tmp_path))
f = tmp_path / "BRMP Rawa" / "01 KK.xlsx"
f.parent.mkdir(parents=True)
f.touch()
cmd, _ = build_rdp_command(cfg, app_executable="excel.exe", file_path=str(f))
assert any(',cmd:"\\\\tsclient\\home\\BRMP Rawa\\01 KK.xlsx"' in c for c in cmd)
def test_file_path_with_space_is_quoted_freerdp2(self, cfg, monkeypatch, tmp_path):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp", "xfreerdp"),
)
monkeypatch.setattr("winpodx.core.rdp.freerdp_major_version", lambda: 2)
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: tmp_path))
f = tmp_path / "BRMP Rawa" / "01 KK.xlsx"
f.parent.mkdir(parents=True)
f.touch()
cmd, _ = build_rdp_command(cfg, app_executable="excel.exe", file_path=str(f))
assert '/app-cmd:"\\\\tsclient\\home\\BRMP Rawa\\01 KK.xlsx"' in cmd
def test_dpi_flag_when_set(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.dpi = 150
cmd, _ = build_rdp_command(cfg)
assert "/scale-desktop:150" in cmd
def test_dynamic_resolution_flag(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cmd, _ = build_rdp_command(cfg)
assert "/dynamic-resolution" in cmd
def test_dynamic_resolution_not_in_app_launch(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cmd, _ = build_rdp_command(
cfg,
app_executable="explorer.exe",
default_args="shell:Desktop",
)
assert any(c.startswith("/app:") for c in cmd)
assert "/dynamic-resolution" not in cmd
def test_span_added_to_app_launch_uniform_scale(self, cfg, monkeypatch):
# multimon defaults to "span": with uniform monitor scales a RAIL app
# launch spans the host monitor bounding box so a window dragged to a
# second monitor keeps input mapping (clicks would otherwise miss).
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr("winpodx.display.layout.has_mixed_scale", lambda: False)
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/span" in cmd
assert "/multimon" not in cmd
def test_span_omitted_on_mixed_scale(self, cfg, monkeypatch):
# Different per-monitor scales -> pin to the primary monitor (no /span):
# FreeRDP RAIL can't span mixed-scale monitors without freezing.
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr("winpodx.display.layout.has_mixed_scale", lambda: True)
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/span" not in cmd
assert "/multimon" not in cmd
def test_span_not_in_full_desktop_launch(self, cfg, monkeypatch):
# The full-desktop path keeps /dynamic-resolution and must not span.
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cmd, _ = build_rdp_command(cfg)
assert "/span" not in cmd
def test_multimon_off_omits_span(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.multimon = "off"
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/span" not in cmd
assert "/multimon" not in cmd
def test_multimon_explicit_uses_multimon_flag(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.multimon = "multimon"
cmd, _ = build_rdp_command(cfg, app_executable="notepad.exe")
assert "/multimon" in cmd
assert "/span" not in cmd
def test_dpi_flag_omitted_when_zero(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.dpi = 0
cmd, _ = build_rdp_command(cfg)
assert not any(c.startswith("/scale-desktop:") for c in cmd)
def test_no_password_with_askpass_uses_askpass_not_stdin(self, cfg, monkeypatch):
# When the config password is empty but askpass is set, the password is
# resolved from askpass and embedded in /p:; no /from-stdin:force.
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
monkeypatch.setattr(
"winpodx.core.rdp._resolve_password",
lambda _cfg: "askpass-secret",
)
cfg.rdp.password = ""
cfg.rdp.askpass = "my-askpass"
cmd, password = build_rdp_command(cfg)
assert password == ""
assert "/from-stdin:force" not in cmd
assert any(c.startswith("/p:askpass-secret") for c in cmd)
def test_domain_flag(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.domain = "WORKGROUP"
cmd, _ = build_rdp_command(cfg)
assert "/d:WORKGROUP" in cmd
def test_extra_flags_filtered(self, cfg, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.extra_flags = "/sound:sys:alsa /exec:evil"
cmd, _ = build_rdp_command(cfg)
assert "/sound:sys:alsa" in cmd
assert "/exec:evil" not in cmd
def test_optional_codec_flags_rejected_as_bare(self, cfg, monkeypatch):
"""Regression for the v0.4.3 → #126 follow-up: FreeRDP 3.x flags
of type ``COMMAND_LINE_VALUE_OPTIONAL`` (gfx-h264, rfx, nsc,
jpeg, avc444) are NOT bare toggles. Including them in
_BARE_FLAGS lets `--extra-args="-gfx-h264"` pass our filter only
for FreeRDP itself to reject with "Unexpected keyword" —
confusing stderr for the user. Reject at the filter so the
misleading flag never reaches xfreerdp3."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.extra_flags = "-gfx-h264 +gfx-h264 -rfx +rfx -nsc +nsc -jpeg +jpeg -avc444 +avc444"
cmd, _ = build_rdp_command(cfg)
for bad in cfg.rdp.extra_flags.split():
assert bad not in cmd, f"OPTIONAL-typed flag leaked through: {bad!r}"
def test_codec_toggles_pass_filter(self, cfg, monkeypatch):
"""Genuine BOOL toggles still pass — wallpaper, themes, decorations,
grab-*, async-*, auto-reconnect, *-cache. Without these in
_BARE_FLAGS they were silently dropped before reaching xfreerdp3.
(gfx-progressive / gfx-thin-client / gfx-small-cache were removed in
#380 — they are `/gfx:` sub-options, not bare toggles; see
``test_gfx_suboptions_and_window_position``. The bare cache toggles
-bitmap-cache / -offscreen-cache / -glyph-cache were likewise removed
when #380 reopened — they are `/cache:<sub>:on|off` sub-options; see
``test_cache_suboptions``.)"""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
toggles = (
"-wallpaper +wallpaper -themes +themes "
"-decorations +decorations "
"-grab-keyboard +grab-keyboard -grab-mouse +grab-mouse "
"-mouse-relative +mouse-relative "
"-async-update +async-update -async-channels +async-channels "
"-auto-reconnect +auto-reconnect"
)
cfg.rdp.extra_flags = toggles
cmd, _ = build_rdp_command(cfg)
for toggle in toggles.split():
assert toggle in cmd, f"toggle dropped by filter: {toggle!r}"
def test_cache_suboptions(self, cfg, monkeypatch):
"""#380 (reopened, FreeRDP 3.26): cache toggles use `/cache:<sub>:on|off`,
not bare `+/-{bitmap,offscreen,glyph}-cache`."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.extra_flags = (
"/cache:bitmap:on /cache:offscreen:off /cache:glyph:on "
"+bitmap-cache -glyph-cache" # stale bare forms must be dropped
)
cmd, _ = build_rdp_command(cfg)
assert "/cache:bitmap:on" in cmd
assert "/cache:offscreen:off" in cmd
assert "/cache:glyph:on" in cmd
assert "+bitmap-cache" not in cmd
assert "-glyph-cache" not in cmd
def test_gfx_suboptions_and_window_position(self, cfg, monkeypatch):
"""#380 (notnotno, FreeRDP 3.26): the gfx sub-options and
window-position use `/name:value` syntax, not bare `+/-` toggles.
- `+gfx-progressive` etc. + `+/-window-position` must be REJECTED
(they passed the old allowlist only for xfreerdp to reject them).
- `/gfx:progressive:on|off`, `/gfx:thin-client:on`,
`/gfx:small-cache:off`, and `/window-position:<x>x<y>` must PASS."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
bad = (
"+gfx-progressive -gfx-progressive "
"+gfx-thin-client -gfx-thin-client "
"+gfx-small-cache -gfx-small-cache "
"+window-position -window-position"
)
cfg.rdp.extra_flags = bad
cmd, _ = build_rdp_command(cfg)
for flag in bad.split():
assert flag not in cmd, f"stale FreeRDP-2 flag leaked: {flag!r}"
good = (
"/gfx:progressive:on /gfx:thin-client:on /gfx:small-cache:off /window-position:100x200"
)
cfg.rdp.extra_flags = good
cmd, _ = build_rdp_command(cfg)
for flag in good.split():
assert flag in cmd, f"correct FreeRDP-3 flag dropped: {flag!r}"
def test_extra_args_kwarg_appended_after_global_extra_flags(self, cfg, monkeypatch):
"""Per-launch `extra_args` (CLI --extra-args / GUI per-launch)
is appended AFTER `cfg.rdp.extra_flags` so per-launch overrides
win when FreeRDP ties on duplicate flags. Uses the same
allowlist filter so the per-launch path can't smuggle anything
unsafe."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
# `aero` is not in the default command, so the only occurrences come
# from global (+) then per-launch (-) — clean ordering check.
cfg.rdp.extra_flags = "+aero" # global says: enable
cmd, _ = build_rdp_command(cfg, extra_args="-aero /not-a-real-flag:bad")
# Both global and per-launch survived the filter.
assert "+aero" in cmd
assert "-aero" in cmd
# Per-launch lands AFTER global; this is the override semantics
# callers rely on.
assert cmd.index("-aero") > cmd.index("+aero")
# Unsafe flag in extra_args is dropped, same as via cfg.rdp.extra_flags.
assert "/not-a-real-flag:bad" not in cmd
def test_extra_args_empty_string_is_noop(self, cfg, monkeypatch):
"""Default (empty extra_args) must not append anything to the
command. Guards against an accidental sentinel like `""` showing
up as an empty-string arg in the FreeRDP command."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cmd_no_extra, _ = build_rdp_command(cfg)
cmd_empty, _ = build_rdp_command(cfg, extra_args="")
assert cmd_no_extra == cmd_empty
assert "" not in cmd_empty
def test_kbd_layout_flag_passes_filter(self, cfg, monkeypatch):
"""/kbd layout flag must survive the allowlist so users can work
around FreeRDP's 'keycode 0x08 no rdp scancode found' warning and
keyboard-layout mismatches (e.g. /kbd:layout:us)."""
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg.rdp.extra_flags = "/kbd:layout:us"
cmd, _ = build_rdp_command(cfg)
assert "/kbd:layout:us" in cmd
def test_linux_to_unc_home_symlink_atomic(monkeypatch, tmp_path):
# Fedora Atomic / Silverblue / Kinoite: /home is a symlink to /var/home.
# Path.home() stays the symlink path; the file resolves to the target.
# Both sides must resolve so the prefix check matches (#418).
real_home = tmp_path / "var_home" / "me"
real_home.mkdir(parents=True)
home_link = tmp_path / "home" / "me"
home_link.parent.mkdir(parents=True)
home_link.symlink_to(real_home) # /home/me -> /var/home/me
doc = real_home / "Desktop" / "temp.doc"
doc.parent.mkdir()
doc.touch()
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: home_link))
monkeypatch.setattr("winpodx.core.rdp._find_media_base", lambda: None)
# File passed through the symlinked home path, as `app run` would.
result = linux_to_unc(str(home_link / "Desktop" / "temp.doc"))
assert result == "\\\\tsclient\\home\\Desktop\\temp.doc"
def test_linux_to_unc_symlinked_subdir_under_home(monkeypatch, tmp_path):
# #547: a subdir under $HOME is itself a symlink pointing out of home
# (~/Documents -> /mnt/store/Documents). The file is still reachable as
# \\tsclient\home\Documents\... because FreeRDP serves $HOME and follows
# symlinks within it; resolving the file path would wrongly reject it.
home = tmp_path / "home" / "me"
home.mkdir(parents=True)
external = tmp_path / "mnt" / "store" / "Documents"
external.mkdir(parents=True)
(home / "Documents").symlink_to(external)
doc = external / "book.xlsx"
doc.touch()
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: home))
monkeypatch.setattr("winpodx.core.rdp._find_media_base", lambda: None)
result = linux_to_unc(str(home / "Documents" / "book.xlsx"))
assert result == "\\\\tsclient\\home\\Documents\\book.xlsx"
def test_linux_to_unc_dotdot_traversal_still_blocked(monkeypatch, tmp_path):
# '..' is collapsed lexically, so a crafted path can't escape $HOME even
# though the file itself is no longer resolve()'d (#547 fix must not weaken
# the containment check).
home = tmp_path / "home" / "me"
home.mkdir(parents=True)
monkeypatch.setattr("winpodx.core.rdp.Path.home", staticmethod(lambda: home))
monkeypatch.setattr("winpodx.core.rdp._find_media_base", lambda: None)
with pytest.raises(ValueError, match="outside shared locations"):
linux_to_unc(str(home / ".." / ".." / "etc" / "passwd"))
class TestResolveWmClass:
"""resolve_wm_class() is the single source of truth shared by FreeRDP's
/wm-class and the .desktop StartupWMClass (taskbar window matching)."""
def test_exe_stem_default(self):
from winpodx.core.rdp import resolve_wm_class
assert resolve_wm_class("C:\\Program Files\\App\\notepad.exe") == "notepad"
def test_exe_hint_overrides_stem(self):
from winpodx.core.rdp import resolve_wm_class
assert resolve_wm_class("C:\\x\\app.exe", "MyApp") == "myapp"
def test_exe_unsafe_hint_falls_back_to_stem(self):
from winpodx.core.rdp import resolve_wm_class
# A hint with disallowed chars must not produce an unsafe token.
assert resolve_wm_class("C:\\x\\app.exe", "bad name!@#") == "app"
def test_uwp_uses_aumid_slug_not_exe_stem(self):
# Regression: a UWP AUMID's exe-stem is useless ("microsoft" from
# "Microsoft.WindowsCalculator_...!App") and never matched the
# StartupWMClass, so Calculator showed up unmatched in the taskbar.
from winpodx.core.rdp import _uwp_fallback_wm_class, resolve_wm_class
aumid = "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
got = resolve_wm_class(None, None, aumid)
assert got == _uwp_fallback_wm_class(aumid)
assert got != "microsoft"
assert got.startswith("winpodx-uwp-")
def test_uwp_valid_hint_wins(self):
from winpodx.core.rdp import resolve_wm_class
aumid = "Microsoft.WindowsCalculator_8wekyb3d8bbwe!App"
assert resolve_wm_class(None, "calc", aumid) == "calc"
def test_desktop_startupwmclass_matches_wm_class(self):
# The .desktop StartupWMClass must equal what FreeRDP gets, for both a
# UWP app and a plain exe.
from winpodx.core.app import AppInfo
from winpodx.core.rdp import resolve_wm_class
uwp = AppInfo(
name="calc",
full_name="Calculator",
executable="Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
launch_uri="Microsoft.WindowsCalculator_8wekyb3d8bbwe!App",
)
token = resolve_wm_class(uwp.executable, uwp.wm_class_hint or None, uwp.launch_uri or None)
assert token.startswith("winpodx-uwp-")
assert token != "microsoft"
class _FakeRun:
def __init__(self, stdout: str = "") -> None:
self.stdout = stdout
self.returncode = 0
def test_relist_uwp_taskbar_clears_skip_states(monkeypatch):
import time
import winpodx.core.rdp as rdp
monkeypatch.setattr(rdp.shutil, "which", lambda _name: "/usr/bin/wmctrl")
monkeypatch.setattr(time, "sleep", lambda _s: None)
lx = (
"0x0140003f 0 RAIL.winpodx-uwp-foo-bar host Calculator\n"
"0x02000010 0 RAIL.other-app host Notepad\n"
)
calls: list[list[str]] = []
def fake_run(cmd, **_kw):
calls.append(cmd)
return _FakeRun(lx if cmd[1] == "-lx" else "")
monkeypatch.setattr(rdp.subprocess, "run", fake_run)
rdp._relist_uwp_taskbar("winpodx-uwp-foo-bar")
removes = [c for c in calls if c[-1] == "remove,skip_taskbar,skip_pager"]
assert removes, "expected a wmctrl remove for the matching RAIL window"
# Only the matching window id is re-listed, never the unrelated RAIL app.
assert all("0x0140003f" in c for c in removes)
assert not any("0x02000010" in c for c in removes)
def test_relist_uwp_taskbar_noop_without_wmctrl(monkeypatch):
import winpodx.core.rdp as rdp
monkeypatch.setattr(rdp.shutil, "which", lambda _name: None)
calls: list = []
monkeypatch.setattr(rdp.subprocess, "run", lambda *a, **k: calls.append(a))
rdp._relist_uwp_taskbar("winpodx-uwp-foo-bar")
assert calls == []
class _FakeProc:
returncode = 0
def poll(self):
return None # alive
def _patch_launch_to_spawn(monkeypatch, tmp_path, cmd):
"""Stub launch_app's surroundings so it reaches the spawn/early-exit path."""
from winpodx.core import rdp as rdp_mod
monkeypatch.setenv("DISPLAY", ":0")
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
monkeypatch.setattr(rdp_mod, "_find_existing_session", lambda _name: None)
monkeypatch.setattr(rdp_mod, "find_freerdp", lambda *a, **k: ("/usr/bin/xfreerdp", "xfreerdp"))
monkeypatch.setattr(rdp_mod, "build_rdp_command", lambda *a, **k: (list(cmd), ""))
monkeypatch.setattr(rdp_mod, "_reaper_thread", lambda _s: None)
spawned: list[list[str]] = []
def fake_spawn(session, spawn_cmd):
spawned.append(list(spawn_cmd))
session.process = _FakeProc()
return session
monkeypatch.setattr(rdp_mod, "_spawn_detached", fake_spawn)
return rdp_mod, spawned
_PRECONNECT_ERR = "ERRCONNECT_PRE_CONNECT_FAILED [0x00020001]\nfreerdp_pre_connect failed"
def test_launch_app_retries_single_monitor_on_span_preconnect_fail(monkeypatch, tmp_path):
# A spanned launch that FreeRDP rejects at pre_connect (mixed-DPI host
# monitors) must auto-retry once with /span dropped, and succeed. With no
# readable X-screen extent (single monitor / no xrandr) the retry is plain
# single-monitor -- no /size added.
rdp_mod, spawned = _patch_launch_to_spawn(
monkeypatch, tmp_path, ["xfreerdp", "/v:127.0.0.1", "/span", "notepad"]
)
monkeypatch.setattr("winpodx.display.layout.detect_x_screen_extent", lambda: None)
seen = {"n": 0}
def fake_early(_session, **_kw):
seen["n"] += 1
return _PRECONNECT_ERR if seen["n"] == 1 else None # fail once, then OK
monkeypatch.setattr(rdp_mod, "_early_exit_stderr", fake_early)
cfg = Config()
cfg.pod.backend = "manual" # skip the interactive-session wait
session = rdp_mod.launch_app(cfg, app_executable="notepad.exe")
assert len(spawned) == 2
assert "/span" in spawned[0]
assert "/span" not in spawned[1] # retry dropped the span
assert not any(f.startswith("/size:") for f in spawned[1]) # no extent -> single
assert session.process is not None
def test_launch_app_retries_geometry_size_on_span_preconnect_fail(monkeypatch, tmp_path):
# When the X-screen extent is known, the retry hands FreeRDP an explicit
# /size desktop spanning both monitors instead of falling to single-monitor.
rdp_mod, spawned = _patch_launch_to_spawn(
monkeypatch, tmp_path, ["xfreerdp", "/v:127.0.0.1", "/span", "notepad"]
)
monkeypatch.setattr("winpodx.display.layout.detect_x_screen_extent", lambda: (5334, 1600))
seen = {"n": 0}
def fake_early(_session, **_kw):
seen["n"] += 1
return _PRECONNECT_ERR if seen["n"] == 1 else None
monkeypatch.setattr(rdp_mod, "_early_exit_stderr", fake_early)
cfg = Config()
cfg.pod.backend = "manual"
session = rdp_mod.launch_app(cfg, app_executable="notepad.exe")
assert len(spawned) == 2
assert "/span" not in spawned[1]
assert "/size:5334x1600" in spawned[1] # explicit both-monitor desktop
assert session.process is not None
def test_launch_app_no_retry_when_no_span(monkeypatch, tmp_path):
# The same pre_connect failure without a span flag is a real error -- no
# retry, raise straight away.
rdp_mod, spawned = _patch_launch_to_spawn(
monkeypatch, tmp_path, ["xfreerdp", "/v:127.0.0.1", "notepad"]
)
monkeypatch.setattr(rdp_mod, "_early_exit_stderr", lambda _s, **_k: _PRECONNECT_ERR)
cfg = Config()
cfg.pod.backend = "manual"
with pytest.raises(RuntimeError, match="exited immediately"):
rdp_mod.launch_app(cfg, app_executable="notepad.exe")
assert len(spawned) == 1 # no retry
def test_launch_app_healthy_spawn_starts_reaper_no_retry(monkeypatch, tmp_path):
# A clean launch (no early exit) spawns once and returns the live session.
rdp_mod, spawned = _patch_launch_to_spawn(
monkeypatch, tmp_path, ["xfreerdp", "/v:127.0.0.1", "/span", "notepad"]
)
monkeypatch.setattr(rdp_mod, "_early_exit_stderr", lambda _s, **_k: None)
cfg = Config()
cfg.pod.backend = "manual"
session = rdp_mod.launch_app(cfg, app_executable="notepad.exe")
assert len(spawned) == 1
assert "/span" in spawned[0]
assert session.process is not None
def test_launch_app_existing_session_with_file_spawns_fresh_window(monkeypatch, tmp_path):
# #680/#2: opening a file while the app is already running no longer attempts
# a (futile under multi-session, ~30s) warm delivery -- it falls straight
# through to a fresh RAIL spawn carrying the file. Prove build_rdp_command is
# reached rather than an early return of the existing session.
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
live = RDPSession(app_name="winword")
monkeypatch.setattr(rdp_mod, "_find_existing_session", lambda _name: live)
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
monkeypatch.setattr(rdp_mod, "linux_to_unc", lambda _p: "\\\\tsclient\\home\\2.docx")
class _ReachedCold(RuntimeError):
pass
def _boom(*a, **k):
raise _ReachedCold()
monkeypatch.setattr(rdp_mod, "build_rdp_command", _boom)
cfg = Config()
cfg.pod.backend = "manual"
with pytest.raises(_ReachedCold):
rdp_mod.launch_app(
cfg,
app_executable="C:\\WINWORD.EXE",
file_path=str(tmp_path / "2.docx"),
)
def test_launch_app_existing_session_without_file_returns_existing(monkeypatch, tmp_path):
# Without a file_path (plain re-launch), the existing session is returned
# immediately -- no fresh spawn.
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
live = RDPSession(app_name="winword")
monkeypatch.setattr(rdp_mod, "_find_existing_session", lambda _name: live)
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
monkeypatch.setattr(
rdp_mod, "build_rdp_command", lambda *a, **k: pytest.fail("must not spawn without a file")
)
cfg = Config()
cfg.pod.backend = "manual"
result = rdp_mod.launch_app(cfg, app_executable="C:\\WINWORD.EXE", file_path=None)
assert result is live
def test_launch_app_warm_session_unmappable_file_notifies_not_silent(monkeypatch, tmp_path):
# #675: a dropped file outside the shared home must surface an error toast
# (parity with the cold path) and NOT spawn a fresh window -- the warm branch
# validates the path up front and notifies instead of falling through.
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
live = RDPSession(app_name="winword")
monkeypatch.setattr(rdp_mod, "_find_existing_session", lambda _name: live)
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
def _raise_value(_p):
raise ValueError("Cannot open file: outside shared locations")
monkeypatch.setattr(rdp_mod, "linux_to_unc", _raise_value)
monkeypatch.setattr(
rdp_mod, "build_rdp_command", lambda *a, **k: pytest.fail("should not spawn a fresh window")
)
notified: list[str] = []
import winpodx.desktop.notify as notify_mod
monkeypatch.setattr(notify_mod, "notify_error", lambda msg: notified.append(msg))
cfg = Config()
cfg.pod.backend = "manual"
result = rdp_mod.launch_app(cfg, app_executable="C:\\WINWORD.EXE", file_path="/etc/passwd")
assert result is live
assert notified and "outside shared locations" in notified[0]
def test_launch_app_cold_path_uses_longer_interactive_timeout(monkeypatch, tmp_path):
# #675 v2: the cold RemoteApp path waits _INTERACTIVE_WAIT_TIMEOUT (bumped
# from 20s) for the guest session to become interactive before creating the
# RAIL window, so a slow guest finishing autologon doesn't get a stale-logon
# framebuffer painted over the app.
from winpodx.core.rdp import _INTERACTIVE_WAIT_TIMEOUT
assert _INTERACTIVE_WAIT_TIMEOUT >= 45
rdp_mod, spawned = _patch_launch_to_spawn(
monkeypatch, tmp_path, ["xfreerdp", "/v:127.0.0.1", "notepad"]
)
monkeypatch.setattr(rdp_mod, "_early_exit_stderr", lambda *a, **k: None)
seen: list = []
def _record_wait(cfg, *, timeout=20):
seen.append(timeout)
return True
monkeypatch.setattr(rdp_mod, "_wait_session_interactive", _record_wait)
cfg = Config()
cfg.pod.backend = "podman" # so the cold-path interactive wait actually fires
rdp_mod.launch_app(cfg, app_executable="notepad.exe")
assert seen == [_INTERACTIVE_WAIT_TIMEOUT]
assert len(spawned) == 1
def test_spawn_detached_sets_wlog_filter_env(monkeypatch, tmp_path):
# #680 nit: the FreeRDP subprocess runs with WLOG_FILTER raising the
# commandline WLog tag to FATAL, silencing the cosmetic get_next_comma
# warning at every launch -- without touching the delivered argv or the
# process-group setup kill_session() relies on.
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
monkeypatch.setattr(rdp_mod, "runtime_dir", lambda: tmp_path)
captured: dict = {}
class _FakeProc:
pid = 4321
def fake_popen(cmd, **kwargs):
captured.update(kwargs)
return _FakeProc()
monkeypatch.setattr(rdp_mod.subprocess, "Popen", fake_popen)
session = rdp_mod._spawn_detached(RDPSession(app_name="excel"), ["xfreerdp", "/v:x"])
assert session.process is not None
env = captured.get("env") or {}
assert env.get("WLOG_FILTER") == "com.winpr.commandline:FATAL"
assert captured.get("start_new_session") is True # PGID preserved for kill_session
def test_count_rail_windows_matches_res_class(monkeypatch):
# #680: RAIL windows are res_name RAIL + res_class == the app_name slug, so
# `wmctrl -lx` column 3 is "RAIL.<app>". Count only exact-class matches.
from winpodx.core import rdp as rdp_mod
out = (
"0x01 0 RAIL.excel host Book1\n"
"0x02 0 RAIL.excel host Book2\n"
"0x03 0 RAIL.winword host Doc\n"
"0x04 0 firefox.Firefox host web\n"
)
monkeypatch.setattr(
rdp_mod.subprocess,
"run",
lambda *a, **k: type("R", (), {"stdout": out, "returncode": 0})(),
)
assert rdp_mod._count_rail_windows("wmctrl", "RAIL.excel") == 2
assert rdp_mod._count_rail_windows("wmctrl", "RAIL.winword") == 1
assert rdp_mod._count_rail_windows("wmctrl", "RAIL.none") == 0
def test_count_rail_windows_none_on_scan_error(monkeypatch):
from winpodx.core import rdp as rdp_mod
def _boom(*a, **k):
raise OSError("wmctrl gone")
monkeypatch.setattr(rdp_mod.subprocess, "run", _boom)
assert rdp_mod._count_rail_windows("wmctrl", "RAIL.excel") is None
class _AliveProc:
pid = 4242 # the reaper passes this as expected_pid to kill_session
def poll(self):
return None # never exits on its own -- the watcher must reap it
def _fast_reaper_tuning(monkeypatch, rdp_mod):
monkeypatch.setattr(rdp_mod, "_WINDOW_REAP_POLL", 0.001)
monkeypatch.setattr(rdp_mod, "_WINDOW_REAP_APPEAR_TIMEOUT", 0.4)
monkeypatch.setattr(rdp_mod, "_WINDOW_REAP_DEBOUNCE", 0.005)
monkeypatch.setattr(rdp_mod.shutil, "which", lambda _n: "/usr/bin/wmctrl")
def test_window_reaper_reaps_after_windows_close(monkeypatch):
# A window appears (arms the watcher), then all windows close -> after the
# debounce the session is reaped via kill_session even though the process
# never exited on its own (#680).
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
_fast_reaper_tuning(monkeypatch, rdp_mod)
# present for the first couple scans, gone forever after.
seq = iter([1, 1])
monkeypatch.setattr(rdp_mod, "_count_rail_windows", lambda *a: next(seq, 0))
killed: list[str] = []
monkeypatch.setattr(
"winpodx.core.process.kill_session",
lambda name, expected_pid=None: killed.append(name),
)
session = RDPSession(app_name="excel")
session.process = _AliveProc()
rdp_mod._window_reaper(session, "excel")
assert killed == ["excel"]
def test_window_reaper_never_reaps_if_no_window_appears(monkeypatch):
# Safe-by-omission: an app whose RAIL window never maps is left entirely to
# the process-reaper -- the watcher must NOT kill it.
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
_fast_reaper_tuning(monkeypatch, rdp_mod)
monkeypatch.setattr(rdp_mod, "_count_rail_windows", lambda *a: 0) # never appears
killed: list[str] = []
monkeypatch.setattr(
"winpodx.core.process.kill_session",
lambda name, expected_pid=None: killed.append(name),
)
session = RDPSession(app_name="excel")
session.process = _AliveProc()
rdp_mod._window_reaper(session, "excel")
assert killed == []
def test_window_reaper_noop_without_wmctrl(monkeypatch):
from winpodx.core import rdp as rdp_mod
from winpodx.core.rdp import RDPSession
monkeypatch.setattr(rdp_mod.shutil, "which", lambda _n: None)
scanned: list = []
monkeypatch.setattr(rdp_mod, "_count_rail_windows", lambda *a: scanned.append(a))
killed: list[str] = []
monkeypatch.setattr(
"winpodx.core.process.kill_session",
lambda name, expected_pid=None: killed.append(name),
)
session = RDPSession(app_name="excel")
session.process = _AliveProc()
rdp_mod._window_reaper(session, "excel")
assert scanned == [] and killed == []
def test_session_state_probe_is_session_scoped(monkeypatch):
# #680/#5: the LOCKED probe must compare LogonUI/explorer by SessionId so a
# lock screen in ANOTHER session (console / stale disconnected RAIL) doesn't
# flip an interactive app session to LOCKED. Guard the exact shape of the PS.
from winpodx.core.rdp import _SESSION_STATE_PS
assert "SessionId" in _SESSION_STATE_PS
assert "-notcontains" in _SESSION_STATE_PS
# all three states still emitted
for state in ("'READY'", "'LOCKED'", "'NOSHELL'"):
assert state in _SESSION_STATE_PS
# the naive machine-wide "any LogonUI -> LOCKED" form is gone
assert "if (Get-Process LogonUI" not in _SESSION_STATE_PS
def _url_cfg() -> Config:
cfg = Config()
cfg.rdp.user = "u"
cfg.rdp.password = "p"
return cfg
def _build_url(monkeypatch, file_path: str, *, major: int = 3):
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod, "find_freerdp", lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"))
monkeypatch.setattr(rdp_mod, "freerdp_major_version", lambda: major)
cmd, _ = rdp_mod.build_rdp_command(
_url_cfg(), app_executable="OUTLOOK.EXE", file_path=file_path
)
return cmd
def test_url_scheme_bypasses_unc_freerdp3(monkeypatch) -> None:
# #421/#694: a mailto: URL is routed to the /app cmd verbatim, not mapped to
# a \\tsclient\home UNC (which would raise "outside home").
cmd = _build_url(monkeypatch, "mailto:foo@bar.com")
app = next(c for c in cmd if c.startswith("/app:"))
assert 'cmd:"mailto:foo@bar.com"' in app
assert "tsclient" not in app
def test_url_scheme_freerdp2(monkeypatch) -> None:
cmd = _build_url(monkeypatch, "slack://team/x", major=2)
assert '/app-cmd:"slack://team/x"' in cmd
def test_url_comma_quote_sanitized(monkeypatch) -> None:
cmd = _build_url(monkeypatch, 'slack://team,chan"x')
app = next(c for c in cmd if c.startswith("/app:"))
# comma + double-quote inside the URL are space-replaced (no sub-key inject)
assert 'cmd:"slack://team chan x"' in app
def test_file_url_still_routed_through_unc(monkeypatch, tmp_path) -> None:
# file: is denylisted -> stays on the UNC path; linux_to_unc strips file://
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod.Path, "home", staticmethod(lambda: tmp_path))
doc = tmp_path / "doc.txt"
doc.touch()
cmd = _build_url(monkeypatch, doc.as_uri()) # file:///…/doc.txt
app = next(c for c in cmd if c.startswith("/app:"))
assert "\\\\tsclient\\home\\doc.txt" in app
def test_dangerous_scheme_not_routed_as_url(monkeypatch, tmp_path) -> None:
# javascript: must NOT reach the guest as a URL; url_scheme_of returns None
# so it falls through to linux_to_unc, which rejects it (not a $HOME path).
from winpodx.core import rdp as rdp_mod
monkeypatch.setattr(rdp_mod.Path, "home", staticmethod(lambda: tmp_path))
monkeypatch.setattr(rdp_mod, "_find_media_base", lambda: None)
with pytest.raises(RuntimeError, match="Cannot open file"):
_build_url(monkeypatch, "javascript:alert(1)")
def test_redact_cmd_for_log_masks_password():
# The FreeRDP argv carries the Windows password as /p: (and gateway pw as
# /gp:); the launch log must not print either in cleartext.
from winpodx.core.rdp import _redact_cmd_for_log
out = _redact_cmd_for_log(
["xfreerdp3", "/v:127.0.0.1:3390", "/u:User", "/p:s3cr3t", "/gp:gwpass", "/cert:ignore"]
)
assert "s3cr3t" not in out
assert "gwpass" not in out
assert "/p:***" in out
assert "/gp:***" in out
assert "/u:User" in out # non-secret tokens are preserved
assert "/v:127.0.0.1:3390" in out
# #660: cfg.pod.keyboard -> FreeRDP /kbd:layout propagation
class TestKeyboardLayoutPropagation:
def test_auto_kbd_flag_default_en_us_is_none(self):
c = Config()
c.pod.keyboard = "en-US"
assert _auto_kbd_flag(c) is None
def test_auto_kbd_flag_empty_is_none(self):
c = Config()
c.pod.keyboard = ""
assert _auto_kbd_flag(c) is None
def test_auto_kbd_flag_full_culture(self):
c = Config()
c.pod.keyboard = "de-DE"
assert _auto_kbd_flag(c) == "/kbd:layout:0x00000407"
def test_auto_kbd_flag_bare_language_fallback(self):
c = Config()
c.pod.keyboard = "hu"
assert _auto_kbd_flag(c) == "/kbd:layout:0x0000040e"
def test_auto_kbd_flag_culture_prefix_fallback(self):
# Unknown region but known language prefix -> falls back to the language.
c = Config()
c.pod.keyboard = "fr-BE"
assert _auto_kbd_flag(c) == "/kbd:layout:0x0000040c"
def test_auto_kbd_flag_unmapped_is_none(self):
c = Config()
c.pod.keyboard = "xx-YY"
assert _auto_kbd_flag(c) is None
def _cfg(self):
c = Config()
c.rdp.ip = "127.0.0.1"
c.rdp.port = 3390
c.rdp.user = "TestUser"
c.rdp.password = "secret"
c.rdp.extra_flags = ""
c.pod.backend = "manual"
return c
def test_non_default_keyboard_appends_kbd(self, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg = self._cfg()
cfg.pod.keyboard = "hu-HU"
cmd, _ = build_rdp_command(cfg)
assert "/kbd:layout:0x0000040e" in cmd
def test_default_keyboard_does_not_append_kbd(self, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg = self._cfg() # default en-US
cmd, _ = build_rdp_command(cfg)
assert not any(c.startswith("/kbd") for c in cmd)
def test_user_extra_flags_kbd_wins(self, monkeypatch):
monkeypatch.setattr(
"winpodx.core.rdp.find_freerdp",
lambda *a, **k: ("/usr/bin/xfreerdp3", "xfreerdp"),
)
cfg = self._cfg()
cfg.pod.keyboard = "hu-HU" # would auto-map to 0x040e
cfg.rdp.extra_flags = "/kbd:layout:0x00000409" # user forces US
cmd, _ = build_rdp_command(cfg)
kbd_flags = [c for c in cmd if c.startswith("/kbd")]
assert kbd_flags == ["/kbd:layout:0x00000409"] # only the user's, no auto