235 lines
12 KiB
Python
235 lines
12 KiB
Python
"""Exercise real inhibitor FD lifetimes and session handshake on an isolated bus.
|
|
|
|
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
|
|
Uses a nested compositor and mock logind; never suspends or ends the host session.
|
|
"""
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
NAME = "org.freedesktop.impl.portal.desktop.ferese"
|
|
PATH = "/org/freedesktop/portal/desktop"
|
|
INTERFACE = "org.freedesktop.impl.portal.Inhibit"
|
|
|
|
|
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_INHIBIT") == "1", "requires a Wayland host")
|
|
class InhibitIntegration(unittest.TestCase):
|
|
def test_private_bus_lifetimes_and_query_end(self):
|
|
result = subprocess.run(["dbus-run-session", "--", sys.executable, __file__, "--private"], capture_output=True, text=True, timeout=40)
|
|
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
|
|
|
|
class IPC:
|
|
def __init__(self, path):
|
|
self.socket = socket.socket(socket.AF_UNIX)
|
|
self.socket.settimeout(3)
|
|
self.socket.connect(str(path))
|
|
|
|
def call(self, command, args=None):
|
|
request = json.dumps({"version": 1, "id": 1, "type": "command", "command": command, "args": args or {}}).encode()
|
|
self.socket.sendall(struct.pack(">I", len(request)) + request)
|
|
def read(size):
|
|
result = b""
|
|
while len(result) < size:
|
|
chunk = self.socket.recv(size - len(result))
|
|
if not chunk:
|
|
raise EOFError("IPC connection closed")
|
|
result += chunk
|
|
return result
|
|
response = json.loads(read(struct.unpack(">I", read(4))[0]))
|
|
if response.get("error"):
|
|
raise ValueError(response["error"])
|
|
return response["result"]
|
|
|
|
def close(self):
|
|
self.socket.close()
|
|
|
|
|
|
def private_test():
|
|
from gi.repository import Gio, GLib
|
|
processes = []
|
|
pipes = []
|
|
pipe_lock = threading.Lock()
|
|
ready = threading.Event()
|
|
login_loop = None
|
|
login_connection = None
|
|
manager_xml = """<node><interface name="org.freedesktop.login1.Manager">
|
|
<method name="Inhibit"><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="s" direction="in"/><arg type="h" direction="out"/></method>
|
|
<method name="ListInhibitors"><arg type="a(ssssuu)" direction="out"/></method>
|
|
<signal name="PrepareForShutdown"><arg type="b"/></signal>
|
|
</interface></node>"""
|
|
|
|
def logind():
|
|
nonlocal login_loop, login_connection
|
|
context = GLib.MainContext.new()
|
|
context.push_thread_default()
|
|
login_connection = Gio.DBusConnection.new_for_address_sync(os.environ["DBUS_SESSION_BUS_ADDRESS"], Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, None, None)
|
|
login_connection.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.login1", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
|
|
def method(connection, sender, object_path, interface, name, params, invocation):
|
|
if name == "Inhibit":
|
|
what, app, reason, mode = params.unpack()
|
|
reader, writer = os.pipe2(os.O_CLOEXEC | os.O_NONBLOCK)
|
|
descriptors = Gio.UnixFDList.new()
|
|
index = descriptors.append(writer)
|
|
with pipe_lock:
|
|
pipes.append((reader, what, app, reason, mode))
|
|
invocation.return_value_with_unix_fd_list(GLib.Variant("(h)", (index,)), descriptors)
|
|
os.close(writer)
|
|
else:
|
|
rows = []
|
|
with pipe_lock:
|
|
for reader, what, app, reason, mode in pipes:
|
|
try:
|
|
if os.read(reader, 1) == b"":
|
|
continue
|
|
except BlockingIOError:
|
|
pass
|
|
rows.append((what, app, reason, mode, os.getuid(), os.getpid()))
|
|
invocation.return_value(GLib.Variant("(a(ssssuu))", (rows,)))
|
|
login_connection.register_object("/org/freedesktop/login1", Gio.DBusNodeInfo.new_for_xml(manager_xml).interfaces[0], method, None, None)
|
|
login_loop = GLib.MainLoop.new(context, False)
|
|
ready.set()
|
|
login_loop.run()
|
|
context.pop_thread_default()
|
|
|
|
thread = threading.Thread(target=logind, daemon=True)
|
|
thread.start()
|
|
assert ready.wait(3), "Mock logind did not start"
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
|
|
def call(interface, method, params=None, signature=None, path=PATH):
|
|
return bus.call_sync(NAME, path, interface, method, params, GLib.VariantType.new(signature) if signature else None, Gio.DBusCallFlags.NO_AUTO_START, 5000, None).unpack()
|
|
|
|
def wait_for(check, message):
|
|
deadline = time.monotonic() + 4
|
|
while time.monotonic() < deadline:
|
|
while GLib.MainContext.default().pending():
|
|
GLib.MainContext.default().iteration(False)
|
|
if check():
|
|
return
|
|
time.sleep(0.01)
|
|
raise AssertionError(message)
|
|
|
|
def active_fd(index):
|
|
with pipe_lock:
|
|
try:
|
|
return os.read(pipes[index][0], 1) != b""
|
|
except BlockingIOError:
|
|
return True
|
|
|
|
with tempfile.TemporaryDirectory(prefix="ferese-inhibit-") as directory:
|
|
root = Path(directory)
|
|
runtime = root / "runtime"
|
|
runtime.mkdir(mode=0o700)
|
|
config = root / "config/ferese/config.kdl"
|
|
config.parent.mkdir(parents=True)
|
|
config.write_text("status { keybinding-guide #false; }\n")
|
|
display = Path(os.environ["WAYLAND_DISPLAY"])
|
|
if not display.is_absolute():
|
|
display = Path(os.environ["XDG_RUNTIME_DIR"]) / display
|
|
env = dict(os.environ, XDG_RUNTIME_DIR=str(runtime), XDG_CONFIG_HOME=str(root / "config"), WAYLAND_DISPLAY=str(display), DBUS_SYSTEM_BUS_ADDRESS=os.environ["DBUS_SESSION_BUS_ADDRESS"])
|
|
env.pop("WAYLAND_SOCKET", None)
|
|
log = (root / "test.log").open("w")
|
|
try:
|
|
compositor = subprocess.Popen([str(REPO / "target/debug/ferese"), "--backend=nested"], env=env, stdout=log, stderr=log, start_new_session=True)
|
|
processes.append(compositor)
|
|
deadline = time.monotonic() + 10
|
|
while not (runtime / "ferese/control.sock").exists():
|
|
assert compositor.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
|
|
time.sleep(0.02)
|
|
backend = subprocess.Popen([str(REPO / "target/debug/xdg-desktop-portal-ferese")], env=env, stdout=log, stderr=log, start_new_session=True)
|
|
processes.append(backend)
|
|
deadline = time.monotonic() + 10
|
|
while True:
|
|
try:
|
|
call("org.freedesktop.DBus.Introspectable", "Introspect", signature="(s)")
|
|
break
|
|
except GLib.Error:
|
|
assert backend.poll() is None and time.monotonic() < deadline, (root / "test.log").read_text()
|
|
time.sleep(0.02)
|
|
request = PATH + "/request/test/inhibit"
|
|
try:
|
|
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (request, "test.media", "", 8, {})))
|
|
raise AssertionError("Untrusted inhibition was accepted")
|
|
except GLib.Error as error:
|
|
assert "AccessDenied" in str(error)
|
|
bus.call_sync("org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "RequestName", GLib.Variant("(su)", ("org.freedesktop.portal.Desktop", 0)), None, Gio.DBusCallFlags.NONE, 2000, None)
|
|
admin = IPC(runtime / "ferese/control.sock")
|
|
def inhibit(path, flags):
|
|
call(INTERFACE, "Inhibit", GLib.Variant("(ossua{sv})", (path, "test.media", "", flags, {"reason": GLib.Variant("s", "Playing media")})))
|
|
inhibit(request, 8)
|
|
assert active_fd(0)
|
|
assert admin.call("get-session-state")["inhibitors"][0]["flags"] == 8
|
|
call("org.freedesktop.impl.portal.Request", "Close", path=request)
|
|
wait_for(lambda: not active_fd(0) and not admin.call("get-session-state")["inhibitors"], "Close retained inhibition")
|
|
session = PATH + "/session/test/monitor"
|
|
changes = []
|
|
closed = []
|
|
bus.signal_subscribe(NAME, INTERFACE, "StateChanged", PATH, None, Gio.DBusSignalFlags.NONE, lambda *args: changes.append(args[-1].unpack()[1]))
|
|
bus.signal_subscribe(NAME, "org.freedesktop.impl.portal.Session", "Closed", session, None, Gio.DBusSignalFlags.NONE, lambda *args: closed.append(True))
|
|
assert call(INTERFACE, "CreateMonitor", GLib.Variant("(ooss)", (PATH + "/request/test/monitor", session, "test.media", "")), "(u)") == (0,)
|
|
wait_for(lambda: any(value["session-state"] == 1 for value in changes), "Missing initial Running")
|
|
assert call("org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", ("org.freedesktop.impl.portal.Session", "version")), "(v)", session) == (1,)
|
|
query = IPC(runtime / "ferese/control.sock")
|
|
state = query.call("begin-session-end")
|
|
assert state["query-ready"] is False
|
|
wait_for(lambda: any(value["session-state"] == 2 for value in changes), "Missing QueryEnd")
|
|
call(INTERFACE, "QueryEndResponse", GLib.Variant("(o)", (session,)))
|
|
assert admin.call("get-session-state")["query-ready"] is True
|
|
approved = state["inhibitor-revision"]
|
|
inhibit(request, 1)
|
|
try:
|
|
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": approved, "force": True})
|
|
raise AssertionError("Late inhibitor bypassed confirmation")
|
|
except ValueError:
|
|
pass
|
|
current = admin.call("get-session-state")
|
|
query.call("validate-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
|
|
assert admin.call("get-session-state")["session-state"] == 2
|
|
query.call("commit-session-end", {"token": state["query-token"], "inhibitor-revision": current["inhibitor-revision"], "force": True})
|
|
wait_for(lambda: any(value["session-state"] == 3 for value in changes), "Missing Ending")
|
|
query.call("cancel-session-end", {"token": state["query-token"]})
|
|
wait_for(lambda: changes[-1]["session-state"] == 1, "Missing cancelled end state")
|
|
query.close()
|
|
call("org.freedesktop.impl.portal.Session", "Close", path=session)
|
|
wait_for(lambda: len(closed) == 1, "Missing Closed")
|
|
count = len(changes)
|
|
login_connection.emit_signal(None, "/org/freedesktop/login1", "org.freedesktop.login1.Manager", "PrepareForShutdown", GLib.Variant("(b)", (True,)))
|
|
wait_for(lambda: admin.call("get-session-state")["session-state"] == 3, "Missing external shutdown state")
|
|
time.sleep(0.1)
|
|
while GLib.MainContext.default().pending():
|
|
GLib.MainContext.default().iteration(False)
|
|
assert len(changes) == count, "StateChanged appeared after Closed"
|
|
compositor.terminate()
|
|
compositor.wait(timeout=5)
|
|
wait_for(lambda: not active_fd(1), "Compositor death retained logind inhibitor FD")
|
|
admin.close()
|
|
print("Inhibit FD, monitor, ACK, late consent and disconnect checks passed")
|
|
finally:
|
|
for process in reversed(processes):
|
|
if process.poll() is None:
|
|
os.killpg(process.pid, signal.SIGTERM)
|
|
process.wait(timeout=5)
|
|
log.close()
|
|
login_loop.quit()
|
|
thread.join(timeout=2)
|
|
for pipe, *_ in pipes:
|
|
os.close(pipe)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if "--private" in sys.argv:
|
|
private_test()
|
|
else:
|
|
unittest.main()
|