"""Exercise protocol bounds, frame ownership and the real tool lifecycle without a browser installation.""" import array import ast import fcntl import mmap import os import runpy import socket import sys from types import SimpleNamespace as NS from threading import Event, Thread from unittest.mock import Mock import pyray as rl import pytest from openpilot.selfdrive.ui.layouts.arabesque.tools import browser, browser_session as runtime from openpilot.system.ui.lib.application import gui_app, MouseEvent, MousePos from openpilot.system.ui.widgets import Widget def test_sealed_frames_cannot_resize_and_roundtrip_fds(): fd = runtime.sealed_slot(4096) left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) try: assert fcntl.fcntl(fd, runtime.GET_SEALS) == runtime.SEALS with pytest.raises(OSError): os.ftruncate(fd, 0) with mmap.mmap(fd, 4096) as writer: writer[:4] = b'BGRX' runtime.packet(left, {'type': 'init'}, [fd]) message, descriptors = runtime.receive(right, 1) try: assert message == {'type': 'init'} assert not os.get_inheritable(descriptors[0]) with mmap.mmap(descriptors[0], 4096, access=mmap.ACCESS_READ) as reader: assert reader[:4] == b'BGRX' finally: os.close(descriptors[0]) finally: os.close(fd) left.close() right.close() @pytest.mark.parametrize('payload', [b'[]', b'{', b'{"type":7}', b'{"type":"x","pad":"' + b'x' * 4096 + b'"}'], ids=['array', 'invalid-json', 'invalid-type', 'oversized']) def test_malformed_or_truncated_packets_are_rejected(payload): left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) try: left.send(payload) with pytest.raises(ValueError): runtime.receive(right) finally: left.close() right.close() def test_unexpected_descriptor_is_closed(): left, right = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) fd = runtime.sealed_slot(4096) before = len(os.listdir('/proc/self/fd')) try: left.sendmsg([b'{"type":"x"}'], [(socket.SOL_SOCKET, socket.SCM_RIGHTS, array.array('i', [fd]))]) with pytest.raises(ValueError): runtime.receive(right) assert len(os.listdir('/proc/self/fd')) == before finally: os.close(fd) left.close() right.close() @pytest.mark.parametrize('values', [(True, 600, 20), (1601, 600, 20), (1440, -1, 20), (1440, 650, 31), (1440, 650, 0)]) def test_geometry_is_bounded(values): with pytest.raises(ValueError): runtime.geometry(*values) def test_transient_launcher_has_no_host_data_or_display_privileges(): command = runtime.launch_command('arabesque-firefox-test', 'test', 1440, 650, 20, 'shm') assert command[:6] == ['sudo', '-n', '/usr/bin/timeout', '--kill-after=1', '15', '/usr/bin/systemd-run'] assert '--property=PrivateTmp=yes' in command assert '--property=ProtectSystem=strict' in command assert not any(part.startswith('--property=ProtectKernelTunables=') for part in command) binds = next(part for part in command if part.startswith('--property=BindReadOnlyPaths=')) assert f'{runtime.RUNTIME}/isolation/devices:/dev' in binds assert all(f'{runtime.RUNTIME}/isolation/empty:{path}' in binds for path in ('/home', '/root', '/run/user')) assert '/sys /proc/sys ' in binds assert ('--property=TemporaryFileSystem=/data:ro /persist:ro /mnt:ro /media:ro /cache:ro /rwtmp:ro /var:ro ' + '/dev/shm:rw,nosuid,nodev,noexec,mode=1777') in command assert '--property=CPUAffinity=0-5' in command assert '--property=KillMode=mixed' in command assert '--property=Restart=no' in command assert '--property=WatchdogSec=4' in command and '--property=LimitNPROC=1024' in command assert '--reuid=61234' in command and '--clear-groups' in command and '--no-new-privs' in command assert not any('NO_SANDBOX' in part or 'disable-sandbox' in part for part in command) def test_first_use_preparer_is_temporary_supervised_and_resource_limited(): command = runtime.preparation_command('arabesque-firefox-test', 'test') assert '--collect' in command assert '--property=WatchdogSec=4' in command assert '--property=RuntimeMaxSec=900' in command assert '--property=CapabilityBoundingSet=CAP_CHOWN' in command assert f'--property=ExecStartPre=-+/usr/bin/mkdir -m 0755 {runtime.ROOT}' in command assert '--property=ProtectSystem=strict' in command assert '--property=ReadWritePaths=/data' in command assert '--property=CPUAffinity=0-5' in command assert '--property=Nice=10' in command and '--property=MemoryMax=1G' in command @pytest.mark.parametrize('cancel', [False, True]) def test_first_use_prepares_without_frames_and_waits_before_launching(tmp_path, monkeypatch, cancel): root = tmp_path / 'firefox' monkeypatch.setattr(runtime, 'ROOT', root) monkeypatch.setattr(runtime, 'RUNTIME', root / 'runtime') monkeypatch.setattr(runtime, 'STATE', root / 'state') monkeypatch.setattr(runtime.BrowserSession, 'pending_stop', None) launch = Mock(return_value=['prepare']) monkeypatch.setattr(runtime, 'preparation_command', launch) monkeypatch.setattr(runtime.subprocess, 'Popen', Mock()) monkeypatch.setattr(runtime, 'stop_unit', lambda *args: None) monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), 0, 0)) monkeypatch.setattr(runtime, 'sealed_slot', Mock(side_effect=AssertionError('Frames allocated before preparation'))) session = runtime.BrowserSession(1440, 650, 20) client = socket.socket(socket.AF_UNIX, socket.SOCK_SEQPACKET) client.settimeout(2) try: assert not session.error and session._preparing and not session.buffers launch.assert_called_once() client.connect(session._listener.getsockname()) session.poll() assert runtime.receive(client)[0] == {'type': 'prepare'} runtime.packet(client, {'type': 'status', 'message': 'Downloading Firefox… 50%'}) session.poll() assert session.status == 'Downloading Firefox… 50%' runtime.packet(client, {'type': 'prepared'}) session.poll() start = Mock() monkeypatch.setattr(session, '_start', start) session.poll() start.assert_not_called() if cancel: session.close() session.stopped.set() session.poll() assert start.call_count == (0 if cancel else 1) finally: client.close() session.close() def test_prepared_runtime_does_not_start_preparation(tmp_path, monkeypatch): root = tmp_path / 'firefox' app = root / 'runtime/firefox/firefox' app.parent.mkdir(parents=True) app.write_bytes(b'firefox') (root / 'runtime/ready.json').write_text('{}') (root / 'state').mkdir() for path in (root, root / 'runtime', app, root / 'state'): path.chmod(0o700 if path == root / 'state' else 0o755) owners = {root: 0, root / 'runtime': 0, root / 'state': runtime.BROWSER_UID, app: 0} original_stat = runtime.Path.stat def stat(path, *args, **kwargs): info = original_stat(path, *args, **kwargs) if path in owners: values = list(info) values[4] = owners[path] return os.stat_result(values) return info monkeypatch.setattr(runtime.Path, 'stat', stat) monkeypatch.setattr(runtime, 'ROOT', root) monkeypatch.setattr(runtime, 'RUNTIME', root / 'runtime') monkeypatch.setattr(runtime, 'STATE', root / 'state') monkeypatch.setattr(runtime.BrowserSession, 'pending_stop', None) prepare, launch = Mock(), Mock(return_value=['browser']) monkeypatch.setattr(runtime, 'preparation_command', prepare) monkeypatch.setattr(runtime, 'launch_command', launch) monkeypatch.setattr(runtime.subprocess, 'Popen', Mock()) monkeypatch.setattr(runtime, 'stop_unit', lambda *args: None) session = runtime.BrowserSession(1440, 650, 20) try: assert not session.error and not session._preparing and len(session.buffers) == 2 prepare.assert_not_called() launch.assert_called_once() finally: session.close() @pytest.mark.parametrize('finish', ['success', 'close', 'eof', 'lease']) def test_preparation_progress_completion_and_owner_cleanup(monkeypatch, finish): client, worker = socket.socketpair(socket.AF_UNIX, socket.SOCK_SEQPACKET) client.settimeout(3) connection = Mock(wraps=worker) connection.connect = Mock() setup, complete, installer_finished = Mock(), Event(), Event() def prepare(): try: setup.report('Downloading Firefox… 50%') complete.wait(3) setup.report('Prepared') finally: installer_finished.set() setup.prepare.side_effect = prepare monkeypatch.setattr(runtime.socket, 'socket', Mock(return_value=connection)) monkeypatch.setattr(runtime.signal, 'signal', Mock()) monkeypatch.setattr(runtime, 'credentials', lambda _: (os.getpid(), os.getuid(), os.getgid())) monkeypatch.setattr(runtime, 'notify', Mock()) monkeypatch.setattr(runtime, 'LEASE_SECONDS', 0.15) monkeypatch.setattr(runtime.sys, 'argv', ['prepare', 'address', str(os.getuid()), str(os.getpid())]) monkeypatch.setattr(runtime.importlib.util, 'spec_from_file_location', Mock(return_value=Mock(loader=Mock()))) monkeypatch.setattr(runtime.importlib.util, 'module_from_spec', Mock(return_value=setup)) runtime.packet(client, {'type': 'prepare'}) thread = Thread(target=runtime.preparation_main, daemon=True) try: thread.start() message, _ = runtime.receive(client) assert message['type'] == 'status' if finish == 'success': complete.set() while message['type'] == 'status': message, _ = runtime.receive(client) assert message == {'type': 'prepared'} elif finish == 'close': runtime.packet(client, {'type': 'close'}) elif finish == 'eof': client.close() thread.join(timeout=2) assert not thread.is_alive() assert setup.ROOT == runtime.ROOT runtime.notify.assert_any_call('READY=1\nWATCHDOG=1') finally: complete.set() thread.join(timeout=3) assert installer_finished.wait(1) client.close() worker.close() def test_take_latest_frame_releases_old_slot_after_bounded_handoff(): session = runtime.BrowserSession.__new__(runtime.BrowserSession) session._frames, session.buffers, session.release = [0, 1], [b'old', b'new'], Mock() session.ready = True assert session.take_frame() == (1, b'new') session.release.assert_called_once_with(0) assert session.take_frame() is None def test_input_is_bounded_and_modifiers_are_explicit(): x, held = Mock(), set() runtime.inject(x, {'type': 'input', 'event': 'motion', 'a': 3, 'b': 9}, held, 1440, 650) x.motion.assert_called_once_with(3, 9) runtime.inject(x, {'type': 'input', 'event': 'key', 'a': 0x61, 'b': 1}, held, 1440, 650) x.key.assert_called_once_with(0x61, ctrl=True, alt=False, shift=False) runtime.inject(x, {'type': 'input', 'event': 'button', 'a': 1, 'b': True}, held, 1440, 650) assert held == {1} for event, a, b in (('motion', 1440, 0), ('motion', -1, 0), ('button', 7, True), ('button', 1, 1), ('key', 0x61, 8)): with pytest.raises(ValueError): runtime.inject(x, {'type': 'input', 'event': event, 'a': a, 'b': b}, held, 1440, 650) @pytest.fixture def tool(monkeypatch): monkeypatch.setattr(browser, 'device', NS(awake=True)) monkeypatch.setattr('openpilot.system.ui.widgets.device', NS(awake=True)) monkeypatch.setattr(gui_app, '_mouse_events', []) for key in ('get_key_pressed', 'get_char_pressed'): monkeypatch.setattr(rl, key, lambda: 0) monkeypatch.setattr(rl, 'set_exit_key', Mock()) monkeypatch.setattr(browser, 'gui_label', Mock()) for key in ('draw_rectangle_rec', 'draw_rectangle_rounded', 'draw_circle_lines'): monkeypatch.setattr(rl, key, Mock()) sessions = [] def create(width, height, fps): session = Mock(width=width, height=height, fps=fps, ready=True, error='', status='shm') session.stopped = Event() session.stopped.set() sessions.append(session) return session create.pending_stop = None monkeypatch.setattr(browser, 'BrowserSession', create) permitted = Mock(return_value=True) view = browser.Browser(permitted) view.set_rect(rl.Rectangle(0, 104, 2160, 976)) yield NS(view=view, sessions=sessions, permitted=permitted) view.hide_event() def touch(view, x, y, *, pressed=False, released=False): gui_app._mouse_events = [MouseEvent(MousePos(x, y), 0, pressed, released, pressed, 0)] Widget.render(view) def test_no_runtime_until_open_and_none_after_close_or_sleep(tool): view, sessions = tool.view, tool.sessions assert sessions == [] view.show_event() first = sessions[0] view.poll_runtime(True) first.poll.assert_called_once_with() view.poll_runtime(False) first.close.assert_called_once() assert view._session is None and view._texture is None and view._shader is None view.poll_runtime(False) assert len(sessions) == 1 view.poll_runtime(True) assert len(sessions) == 2 view.hide_event() sessions[1].close.assert_called_once() view.hide_event() sessions[1].close.assert_called_once() assert view._prepare not in gui_app._render_prepare def test_scroll_is_not_a_click_and_pointer_disable_releases(tool): view = tool.view view.show_event() session = tool.sessions[0] touch(view, 600, 400, pressed=True) assert not any(c.args[0].get('event') == 'button' for c in session.send.call_args_list) touch(view, 600, 250) touch(view, 600, 250, released=True) buttons = [c.args[0] for c in session.send.call_args_list if c.args[0].get('event') == 'button'] assert buttons and all(p['a'] in (4, 5) for p in buttons) session.send.reset_mock() view._pointer = True touch(view, 600, 400, pressed=True) assert any(c.args[0] == {'type': 'input', 'event': 'button', 'a': 1, 'b': True} for c in session.send.call_args_list) view.set_enabled(False) session.reset_input.assert_called_once() touch(view, 600, 400, released=True) assert view._gesture is None def test_keyboard_geometry_pans_without_distorting_or_mapping_chrome(tool): view = tool.view view.show_event() assert view._point(MousePos(100, 120)) is None factor = view._factor view._keyboard = True view._update_layout_rects() assert view._factor == factor assert view._point(MousePos(100, 1000)) is None view._scroll_y = 10000 view._update_layout_rects() assert 0 < view._scroll_y < view._session.height def test_fast_wake_waits_for_old_process_group_and_keeps_geometry(tool): view = tool.view view.show_event() old = tool.sessions[0] height = old.height view._keyboard = True view._update_layout_rects() old.stopped.clear() view.poll_runtime(False) view.poll_runtime(True) assert len(tool.sessions) == 1 and view._session is None old.stopped.set() view.poll_runtime(True) assert len(tool.sessions) == 2 and tool.sessions[-1].height == height def test_new_browser_waits_for_previous_widget_cleanup(tool): tool.view.show_event() old = tool.sessions[0] old.stopped.clear() old.close.side_effect = lambda: setattr(browser.BrowserSession, 'pending_stop', old.stopped) tool.view.hide_event() view = browser.Browser(tool.permitted) view.set_rect(rl.Rectangle(0, 104, 2160, 976)) try: view.show_event() view.poll_runtime(True) assert len(tool.sessions) == 1 and view._session is None old.stopped.set() view.poll_runtime(True) assert len(tool.sessions) == 2 and view._session is tool.sessions[-1] finally: view.hide_event() def test_failed_frame_release_does_not_return_a_closed_mapping(): session = runtime.BrowserSession.__new__(runtime.BrowserSession) session._frames, session.buffers, session.ready = [0, 1], [b'old', b'new'], True def disconnect(slot): session.ready = False session.buffers = [] session.release = Mock(side_effect=disconnect) assert session.take_frame() is None def test_stop_command_and_launcher_are_reaped_even_on_failure(monkeypatch): stopped, launcher = Event(), Mock() monkeypatch.setattr(runtime.subprocess, 'run', Mock(side_effect=OSError('supervisor unavailable'))) runtime.stop_unit('arabesque-firefox-test', launcher, stopped) launcher.wait.assert_called_once_with(timeout=12) assert stopped.is_set() def test_firefox_quits_cleanly_before_using_signals(monkeypatch): process, x = Mock(), Mock() process.poll.side_effect = [None, None, 0, 0] monkeypatch.setattr(runtime.time, 'sleep', Mock()) runtime.stop_firefox(process, x) x.key.assert_called_once_with(ord('q'), ctrl=True) process.terminate.assert_not_called() @pytest.mark.parametrize('broken_display', [True, False]) def test_firefox_stop_still_forces_exit_on_display_error_or_hang(monkeypatch, broken_display): process, x = Mock(), Mock() process.poll.return_value = None if broken_display: x.key.side_effect = RuntimeError('X display closed') monkeypatch.setattr(runtime.time, 'monotonic', Mock(side_effect=[0, 2])) runtime.stop_firefox(process, x) process.terminate.assert_called_once() process.wait.assert_called_once_with(timeout=1) def test_tab_probe_uses_cgroup_without_proc_children_and_matches_parent(tmp_path, monkeypatch): proc = tmp_path / 'proc' roles = {10: ('firefox', b'firefox\0'), 20: ('forkserver', b'firefox -contentproc forkserver\0'), 30: ('file:// Content', b'firefox -contentproc -isForBrowser -parentPid 10 5 tab\0')} for pid, (name, command) in roles.items(): base = proc / str(pid) base.mkdir(parents=True) (base / 'cmdline').write_bytes(command) (base / 'status').write_text(f'Name:\t{name}\nUid:\t{os.getuid()}\nSeccomp:\t2\nNoNewPrivs:\t1\nCapEff:\t0000\n') (proc / '10/cgroup').write_text('0::/test.service\n') cgroup = tmp_path / 'cgroup' (cgroup / 'test.service').mkdir(parents=True) (cgroup / 'test.service/cgroup.procs').write_text('10\n20\n30\n') real_path = runtime.Path def fake_path(path): if str(path).startswith('/proc/'): return proc / str(path)[6:] return cgroup if str(path) == '/sys/fs/cgroup' else real_path(path) monkeypatch.setattr(runtime, 'Path', fake_path) assert runtime.content_sandboxed(10) (proc / '30/status').write_text(f'Name:\tfile:// Content\nUid:\t{os.getuid()}\nSeccomp:\t2\nCapEff:\t0000\n') monkeypatch.setattr(runtime, 'no_new_privileges', lambda: 0) assert not runtime.content_sandboxed(10) monkeypatch.setattr(runtime, 'no_new_privileges', lambda: 1) assert runtime.content_sandboxed(10) (proc / '30/cmdline').write_bytes(b'firefox -contentproc -isForBrowser -parentPid 11 5 tab\0') assert not runtime.content_sandboxed(10) (proc / '30/cmdline').write_bytes(b'firefox -contentproc -parentPid 10 5 utility\0') assert not runtime.content_sandboxed(10) def test_browser_poll_follows_fresh_state_before_any_render(): path = browser.__file__ from pathlib import Path path = Path(path).parents[3] / 'ui.py' tree = ast.parse(path.read_text()) tree.body = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == 'update_frame'] events = [] namespace: dict = {'gui_app': NS(measure_frame_phase=lambda _, callback: callback()), 'ui_state': NS(update=lambda: events.append('state'))} exec(compile(ast.fix_missing_locations(tree), str(path), 'exec'), namespace) namespace['update_frame'](arabesque=lambda: events.append('browser')) assert events == ['state', 'browser'] def test_diagnostic_entry_cannot_start_without_fresh_offroad(monkeypatch): authority = Mock() authority.offroad.return_value = False monkeypatch.setitem(sys.modules, 'openpilot.common.params', NS(Params=Mock())) monkeypatch.setitem(sys.modules, 'openpilot.starpilot.galaxy.settings', NS(LiveContextSource=Mock(return_value=authority))) monkeypatch.setattr(sys, 'argv', [runtime.__file__, '--seconds', '1']) monkeypatch.setattr(sys, 'path', list(sys.path)) launch = Mock() monkeypatch.setattr(runtime.subprocess, 'Popen', launch) with pytest.raises(SystemExit, match='fresh off-road authority'): runpy.run_path(runtime.__file__, run_name='__main__') launch.assert_not_called() authority.close.assert_called_once() def test_wake_touch_and_revoked_authority_never_inject(tool): view = tool.view view.show_event() view.poll_runtime(False) view.poll_runtime(True) touch(view, 600, 400, pressed=True) assert view._gesture is None assert not tool.sessions[-1].send.called tool.permitted.return_value = False view.poll_runtime(True) assert view._session is None tool.sessions[-1].close.assert_called_once() def test_commit_exact_unicode_without_terminal_normalization(tool): view = tool.view view.show_event() view._text('é€\u0301😀') assert [c.args[0]['a'] for c in tool.sessions[0].send.call_args_list] == [0xe9, 0x010020ac, 0x01000301, 0x0101f600] @pytest.mark.skipif(os.environ.get('ARABESQUE_GPU_TEST') != '1', reason='Needs a disposable Xvfb with GLX') def test_real_texture_swizzle_alpha_and_reuse(): rl.init_window(320, 240, 'Arabesque frame check') view = browser.Browser(lambda: True) fd = runtime.sealed_slot(320 * 240 * 4) buffer = mmap.mmap(fd, 320 * 240 * 4) session = Mock(ready=True, width=320, height=240, error='') session.take_frame.return_value = (0, buffer) view._session = session try: texture_id = None for colors in ((0xff0000, 0x00ff00, 0x0000ff, 0xffffff), (0xffffff, 0x0000ff, 0x00ff00, 0xff0000)): row = b''.join(color.to_bytes(4, 'little') * 80 for color in colors) buffer[:] = row * 240 view._prepare() assert not session.fail.called assert view._texture is not None if texture_id is not None: assert view._texture.id == texture_id texture_id = view._texture.id rl.begin_drawing() rl.clear_background(rl.BLACK) rl.begin_shader_mode(view._shader) bounds = rl.Rectangle(0, 0, 320, 240) rl.draw_texture_pro(view._texture, bounds, bounds, rl.Vector2(0, 0), 0, rl.WHITE) rl.end_shader_mode() image = rl.load_image_from_screen() try: for i, expected in enumerate(colors): actual = rl.get_image_color(image, i * 80 + 40, 120) assert (actual.r, actual.g, actual.b, actual.a) == (expected >> 16, expected >> 8 & 255, expected & 255, 255) finally: rl.unload_image(image) rl.end_drawing() assert session.release.call_count == 2 finally: view.hide_event() buffer.close() os.close(fd) rl.close_window()