"""Tests for the Linux desktop sandbox-helper fixup and the userns probe. ``_desktop_linux_sandbox_fixup`` historically demanded a root-owned 4755 ``chrome-sandbox`` on every Linux host and shelled out to ``sudo`` to get it — which fails silently when the desktop entry launches ``hermes desktop`` without a TTY (#88032, #51327), and blocked the updater's relaunch gate (#58593). On hosts where unprivileged user namespaces work, Chromium uses its namespace sandbox and never consults the setuid helper, so the fixup now probes for that capability first and skips the sudo path entirely. """ from __future__ import annotations import contextlib import io import stat import subprocess import sys import threading from unittest.mock import Mock, patch import pytest from hermes_cli import main_desktop # Linux-only subject: run on the real Linux host instead of faking sys.platform. pytestmark = pytest.mark.platforms("linux") class TestDesktopLinuxUsernsSandboxAvailable: def test_false_when_unshare_is_missing(self, monkeypatch): with patch.object(main_desktop.shutil, "which", return_value=None): assert main_desktop._desktop_linux_userns_sandbox_available() is False def test_true_when_probe_succeeds(self, monkeypatch): with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ patch.object(main_desktop.subprocess, "run") as run: run.return_value.returncode = 0 assert main_desktop._desktop_linux_userns_sandbox_available() is True probe = run.call_args.args[0] assert probe[0] == "/usr/bin/unshare" assert "--user" in probe def test_false_when_probe_fails(self, monkeypatch): """EPERM from the kernel (userns disabled or AppArmor-restricted).""" with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ patch.object(main_desktop.subprocess, "run") as run: run.return_value.returncode = 1 assert main_desktop._desktop_linux_userns_sandbox_available() is False def test_false_when_probe_raises(self, monkeypatch): with patch.object(main_desktop.shutil, "which", return_value="/usr/bin/unshare"), \ patch.object( main_desktop.subprocess, "run", side_effect=subprocess.TimeoutExpired(cmd="unshare", timeout=5), ): assert main_desktop._desktop_linux_userns_sandbox_available() is False class TestDesktopLinuxSandboxFixup: def _fake_packaged_app(self, tmp_path): """Unpacked-app layout with a non-root, non-setuid chrome-sandbox.""" unpacked = tmp_path / "linux-unpacked" unpacked.mkdir() exe = unpacked / "Hermes" exe.write_text("", encoding="utf-8") sandbox = unpacked / "chrome-sandbox" sandbox.write_text("", encoding="utf-8") sandbox.chmod(0o755) return exe def test_userns_host_skips_sudo_and_succeeds(self, monkeypatch, tmp_path): """A user-owned helper must not trigger sudo when userns works. This is the .desktop-launch regression: no TTY means sudo cannot prompt, so reaching the sudo path at all kills the launch. """ exe = self._fake_packaged_app(tmp_path) with patch.object( main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ), \ patch.object(main_desktop.subprocess, "run") as run: assert main_desktop._desktop_linux_sandbox_fixup(exe) is True run.assert_not_called() def test_restricted_host_without_sudo_still_fails(self, monkeypatch, tmp_path): """The pre-existing strict path is preserved when userns is unusable.""" exe = self._fake_packaged_app(tmp_path) with patch.object( main_desktop, "_desktop_linux_userns_sandbox_available", return_value=False ), \ patch.object(main_desktop.shutil, "which", return_value=None): assert main_desktop._desktop_linux_sandbox_fixup(exe) is False def test_root_owned_setuid_helper_short_circuits(self, monkeypatch, tmp_path): """A correctly configured helper wins before the userns probe runs.""" exe = self._fake_packaged_app(tmp_path) real_lstat = (exe.parent / "chrome-sandbox").lstat() class _RootSetuidStat: st_mode = stat.S_IFREG | 0o4755 st_uid = 0 def __getattr__(self, name): return getattr(real_lstat, name) with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \ patch.object( main_desktop, "_desktop_linux_userns_sandbox_available" ) as probe: assert main_desktop._desktop_linux_sandbox_fixup(exe) is True probe.assert_not_called() class TestDesktopLinuxNeedsDisableSetuidSandbox: def _fake_packaged_app(self, tmp_path): unpacked = tmp_path / "linux-unpacked" unpacked.mkdir() exe = unpacked / "Hermes" exe.write_text("", encoding="utf-8") sandbox = unpacked / "chrome-sandbox" sandbox.write_text("", encoding="utf-8") sandbox.chmod(0o755) return exe def test_true_for_user_owned_helper_when_userns_works(self, monkeypatch, tmp_path): exe = self._fake_packaged_app(tmp_path) with patch.object( main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ): assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is True def test_false_for_root_owned_setuid_helper(self, monkeypatch, tmp_path): exe = self._fake_packaged_app(tmp_path) real_lstat = (exe.parent / "chrome-sandbox").lstat() class _RootSetuidStat: st_mode = stat.S_IFREG | 0o4755 st_uid = 0 def __getattr__(self, name): return getattr(real_lstat, name) with patch.object(main_desktop.Path, "lstat", return_value=_RootSetuidStat()), \ patch.object( main_desktop, "_desktop_linux_userns_sandbox_available", return_value=True ) as probe: assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False probe.assert_not_called() def test_false_when_helper_missing(self, monkeypatch, tmp_path): unpacked = tmp_path / "linux-unpacked" unpacked.mkdir() exe = unpacked / "Hermes" exe.write_text("", encoding="utf-8") assert main_desktop._desktop_linux_needs_disable_setuid_sandbox(exe) is False class TestDesktopLinuxSandboxFixupNoTty: """No-TTY sudo must fail fast, never hang the launch (#123927). When the .desktop entry (``Terminal=false``), autostart, or a detached updater relaunch reaches the sudo path — a user-owned ``chrome-sandbox`` plus no usable user namespace (hardened kernels such as CachyOS's ``linux-cachyos-hardened``, ``user.max_user_namespaces=0``, containers) — sudo's password prompt can never be answered. The call used to inherit stdio with no ``-n`` and no timeout, so the icon click hung indefinitely. """ def _fake_packaged_app(self, tmp_path): unpacked = tmp_path / "linux-unpacked" unpacked.mkdir(exist_ok=True) exe = unpacked / "Hermes" exe.write_text("", encoding="utf-8") sandbox = unpacked / "chrome-sandbox" sandbox.write_text("", encoding="utf-8") with contextlib.suppress(OSError): sandbox.chmod(0o755) return exe def test_sudo_is_non_interactive_without_a_tty(self, monkeypatch, tmp_path): """Without a TTY sudo gets ``-n`` + DEVNULL stdin + a timeout.""" exe = self._fake_packaged_app(tmp_path) calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) return Mock(returncode=0) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(sys, "stdin", io.StringIO()) # isatty() is False monkeypatch.setattr( main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False) monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo") monkeypatch.setattr(main_desktop.subprocess, "run", fake_run) assert main_desktop._desktop_linux_sandbox_fixup(exe) is True assert len(calls) == 2 for cmd, kwargs in calls: assert cmd[0] == "/usr/bin/sudo" assert "-n" in cmd assert kwargs.get("stdin") == subprocess.DEVNULL assert kwargs.get("timeout") is not None def test_sudo_is_non_interactive_with_an_unusable_stdin(self, monkeypatch, tmp_path): """A detached launch (``sys.stdin is None``) or a closed stream still fails fast. CPython sets ``sys.stdin`` to ``None`` when the process has no stdin at all, and ``isatty()`` on a closed stream raises; both used to escape as an ``AttributeError``/``ValueError`` before the caller's ``--no-sandbox`` fallback could run. """ exe = self._fake_packaged_app(tmp_path) for state in ("missing", "closed"): calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) return Mock(returncode=0) if state == "missing": monkeypatch.setattr(sys, "stdin", None) else: closed = io.StringIO() closed.close() monkeypatch.setattr(sys, "stdin", closed) monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr( main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False) monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo") monkeypatch.setattr(main_desktop.subprocess, "run", fake_run) assert main_desktop._desktop_linux_sandbox_fixup(exe) is True, state assert len(calls) == 2, state for cmd, kwargs in calls: assert "-n" in cmd, state assert kwargs.get("stdin") == subprocess.DEVNULL, state def test_sudo_stays_interactive_with_a_tty(self, monkeypatch, tmp_path): """A terminal launch keeps the password prompt (no -n).""" exe = self._fake_packaged_app(tmp_path) calls = [] def fake_run(cmd, **kwargs): calls.append((cmd, kwargs)) return Mock(returncode=0) tty = Mock() tty.isatty.return_value = True monkeypatch.setattr(sys, "platform", "linux") monkeypatch.setattr(sys, "stdin", tty) monkeypatch.setattr( main_desktop, "_desktop_linux_userns_sandbox_available", lambda: False) monkeypatch.setattr(main_desktop.shutil, "which", lambda _name: "/usr/bin/sudo") monkeypatch.setattr(main_desktop.subprocess, "run", fake_run) assert main_desktop._desktop_linux_sandbox_fixup(exe) is True assert len(calls) == 2 for cmd, _kwargs in calls: assert "-n" not in cmd def test_blocking_sudo_returns_fast(self, tmp_path): """A sudo stuck at its password prompt must not hang the fixup. The fake honours ``-n`` (exit 1, like a real sudo with no cached credentials) and blocks otherwise (the pre-fix hang: a prompt with no TTY to answer it). Needs a POSIX shell, so Linux-only like the rest of this file. """ exe = self._fake_packaged_app(tmp_path) fake_bin = tmp_path / "bin" fake_bin.mkdir() fake = fake_bin / "sudo" fake.write_text( "#!/bin/sh\n" 'for a in "$@"; do if [ "$a" = "-n" ]; then exit 1; fi; done\n' "sleep 30\n", encoding="utf-8", ) fake.chmod(0o755) outcome: dict = {} def target(): outcome["result"] = main_desktop._desktop_linux_sandbox_fixup(exe) with patch.object(sys, "platform", "linux"), \ patch.object( main_desktop, "_desktop_linux_userns_sandbox_available", return_value=False), \ patch.object(main_desktop.shutil, "which", return_value=str(fake)), \ patch.object(sys, "stdin", io.StringIO()): worker = threading.Thread(target=target, daemon=True) worker.start() worker.join(timeout=10) assert not worker.is_alive(), \ "fixup hung in sudo with no TTY (icon click goes nowhere)" assert outcome.get("result") is False