Fix guarded X11 text mapping without group switching

This commit is contained in:
Gerardo Betancourt committed 2026-10-28 09:07:05 -04:00
1 parent 7a27f7fd55
commit b283999f5a
7 files changed
+1857 -4

No files matched your search

+1 -1
View File
@@ -63,7 +63,7 @@ class NativeDispatchTests(unittest.TestCase):
def test_postflight_failure_after_input_retains_uncertain_outcome(self):
calls = []
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "type", "x"]))
cua = self.fake(calls, lambda engine: engine._run(["xdotool", "key", "x"]))
state = nh.install_cua_guard(cua, env={}, checker=self.checker(3))
with self.assertRaises(CuaError):
cua.handle_cua_message({})
+338
View File
@@ -0,0 +1,338 @@
"""Native-host TYPE interception tests with an entirely fake CUA/display.
No captured code, browser, display, library loader, or native input is used.
"""
import importlib.util
import ast
import ctypes as C
from pathlib import Path
import sys
import subprocess
from types import ModuleType, SimpleNamespace
import unittest
from unittest.mock import Mock
PACKAGE = "_framebridge_native_host_x11_test"
ROOT = Path(__file__).parent
if not (ROOT / "x11_text.py").is_file():
ROOT = ROOT.parent / "expanded/framebridge_expanded"
package = ModuleType(PACKAGE)
package.__path__ = [str(ROOT)]
display = ModuleType(PACKAGE + ".display_context")
class DisplayContextError(RuntimeError):
pass
display.DisplayContextError = DisplayContextError
display.check = lambda env: None
display.describe = lambda env: {"identity": "fixture-owner"}
sys.modules[PACKAGE] = package
sys.modules[display.__name__] = display
text_spec = importlib.util.spec_from_file_location(PACKAGE + ".x11_text", ROOT / "x11_text.py")
text_module = importlib.util.module_from_spec(text_spec)
sys.modules[text_spec.name] = text_module
text_spec.loader.exec_module(text_module)
spec = importlib.util.spec_from_file_location(PACKAGE + ".native_host", ROOT / "native_host.py")
native_host = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = native_host
spec.loader.exec_module(native_host)
class CuaTransportError(RuntimeError):
def __init__(self, code, detail=None):
super().__init__(code)
self.code = code
self.detail = detail
class FakeCua:
CuaTransportError = CuaTransportError
def __init__(self, *, with_base=False):
self.calls = []
self.context_calls = []
self.context = {"before_id": 101, "width": 1920, "height": 1080}
self.argv = None
self.mutate_context_after_capture = False
self.token = object()
if with_base:
self._run_v3_base = self._primitive
def _primitive(self, argv, timeout=15):
self.calls.append((list(argv), timeout))
return self.token
def _run(self, argv, timeout=15):
if hasattr(self, "_run_v3_base"):
return self._run_v3_base(argv, timeout=timeout)
return self._primitive(argv, timeout=timeout)
def _action_context(self, message, *args, **kwargs):
self.context_calls.append((message, args, kwargs))
return self.context
def handle_cua_message(self, message):
self._action_context(message)
if self.mutate_context_after_capture:
self.context.update(before_id=999, width=800, height=600)
argv = self.argv
if argv is None:
if message.get("action") == "TYPE":
argv = ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", message["text"]]
else:
argv = ["xdotool", "getactivewindow"]
return self._run(argv, timeout=7)
class NativeHostX11Tests(unittest.TestCase):
def install(self, cua=None, sender=None, checker=None):
cua = cua or FakeCua()
sender = sender or Mock(return_value=None)
checker = checker or Mock()
state = native_host.install_cua_guard(cua, env={"DISPLAY": ":fixture"},
checker=checker, text_sender=sender)
return cua, sender, checker, state
def test_exact_type_intercept_carries_original_context_and_timeout(self):
cua, sender, _, state = self.install()
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/test"})
self.assertIsInstance(result, subprocess.CompletedProcess)
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout, "")
self.assertEqual(result.stderr, "")
self.assertEqual(result.args, ["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "-S /run/test"])
self.assertEqual(cua.calls, [])
self.assertEqual(len(cua.context_calls), 1)
args, kwargs = sender.call_args
self.assertEqual(args, ("-S /run/test",))
self.assertEqual(kwargs["expected_window"], 101)
self.assertEqual(kwargs["expected_geometry"], (1920, 1080))
self.assertEqual(kwargs["timeout"], 7)
self.assertTrue(callable(kwargs["before_input"]))
self.assertFalse(state["input_started"])
def test_captured_context_is_a_snapshot_not_a_mutable_alias(self):
cua = FakeCua()
cua.mutate_context_after_capture = True
cua, sender, _, _ = self.install(cua)
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertEqual(sender.call_args.kwargs["expected_window"], 101)
self.assertEqual(sender.call_args.kwargs["expected_geometry"], (1920, 1080))
def test_intercepts_when_captured_runner_has_a_base_runner(self):
cua, sender, _, _ = self.install(FakeCua(with_base=True))
cua.handle_cua_message({"action": "TYPE", "text": "/"})
sender.assert_called_once()
self.assertEqual(cua.calls, [])
def test_unexpected_type_arguments_fail_closed(self):
forms = [
["xdotool", "type", "--delay", "10", "--", "/"],
["xdotool", "type", "--clearmodifiers", "--delay", "0", "--", "/"],
["xdotool", "type", "--clearmodifiers", "--delay", "10", "--", "/", "extra"],
["xdotool", "type", "--window", "999", "--", "/"],
["xdotool", "TYPE", "--clearmodifiers", "--delay", "10", "--", "/"],
]
for argv in forms:
with self.subTest(argv=argv):
cua = FakeCua()
cua.argv = argv
cua, sender, _, state = self.install(cua)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertEqual(cua.calls, [])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_missing_original_context_rejects_before_sender_or_runner(self):
cua = FakeCua()
cua.context = {}
cua, sender, _, state = self.install(cua)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
sender.assert_not_called()
self.assertEqual(cua.calls, [])
self.assertFalse(state["input_started"])
def test_non_type_query_passes_through_and_stays_read_only(self):
cua, sender, _, state = self.install()
result = cua.handle_cua_message({"action": "QUERY"})
self.assertIs(result, cua.token)
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_non_type_query_with_invalid_action_context_still_passes_through(self):
cua = FakeCua()
cua.context = {"unrelated_query_context": True}
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "getactivewindow"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_search_literal_type_is_a_read_only_query(self):
cua = FakeCua()
cua.context = {"query_context": True}
cua.argv = ["xdotool", "search", "--name", "type"]
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "QUERY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "search", "--name", "type"], 7)])
sender.assert_not_called()
self.assertFalse(state["input_started"])
def test_clipboard_context_passes_through_without_clipboard_access(self):
cua = FakeCua()
cua.context = {"clipboard_context_is_not_type_keys_context": True}
cua.argv = ["xdotool", "key", "--clearmodifiers", "ctrl+v"]
cua, sender, _, state = self.install(cua)
result = cua.handle_cua_message({"action": "TYPE", "type_mode": "CLIPBOARD_EXACT", "text": "fixture"})
self.assertIs(result, cua.token)
self.assertEqual(cua.calls, [(["xdotool", "key", "--clearmodifiers", "ctrl+v"], 7)])
sender.assert_not_called()
self.assertTrue(state["input_started"])
def test_actual_frozen_type_consumes_completed_process_contract(self):
candidates = [Path(__file__).with_name("frozen_type_fixture.py")]
for parent in [ROOT, *ROOT.parents]:
candidates.append(parent / "sources/original-asus/framebridge-native/host/framebridge_cua_linux.py")
source = next((path for path in candidates if path.is_file()), None)
if source is None:
self.skipTest("captured original _type source is not available in this checkout")
tree = ast.parse(source.read_text())
definitions = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "_type"]
self.assertTrue(definitions, str(source))
cua = FakeCua()
namespace = {"CuaTransportError": CuaTransportError,
"_run": lambda argv, timeout: cua._run(argv, timeout=timeout),
"_action_context": lambda message: cua._action_context(message),
"_evidence_lines": lambda context: []}
exec(compile(ast.Module(body=[definitions[-1]], type_ignores=[]), str(source), "exec"), namespace)
cua.handle_cua_message = namespace["_type"]
cua, sender, _, state = self.install(cua)
result = cua.handle_cua_message({"action": "TYPE", "text": "-S /run/fixture"})
self.assertTrue(result["ok"])
self.assertEqual(result["action"], "TYPE")
self.assertIn("RESULTADO_FINAL=CUA_TYPE_PASS", result["_frame_output"])
self.assertEqual(sender.call_args.kwargs["timeout"], 20)
self.assertEqual(cua.calls, [])
def test_non_type_input_keeps_existing_input_tracking(self):
cua = FakeCua()
cua.argv = ["xdotool", "key", "Return"]
cua, sender, _, state = self.install(cua)
self.assertIs(cua.handle_cua_message({"action": "KEY"}), cua.token)
self.assertEqual(cua.calls, [(["xdotool", "key", "Return"], 7)])
sender.assert_not_called()
self.assertTrue(state["input_started"])
def test_sender_preflight_failure_reports_no_input(self):
# Use the same error class the native sender exports, without loading
# or instantiating its native backend.
sender = Mock(side_effect=text_module.TextError("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP"))
cua, _, _, state = self.install(sender=sender)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "\u2603"})
self.assertFalse(state["input_started"])
self.assertEqual(cua.calls, [])
response = native_host.decorate_response({"executed": False}, state, {})
self.assertFalse(response["input_may_have_been_sent"])
self.assertFalse(response["do_not_replay"])
self.assertFalse(response["performed"])
def test_input_started_only_when_sender_calls_guard(self):
observed = []
holder = {}
def sender(text, **kwargs):
observed.append(holder["state"]["input_started"])
kwargs["before_input"]()
observed.append(holder["state"]["input_started"])
cua, _, _, state = self.install(sender=sender)
holder["state"] = state
cua.handle_cua_message({"action": "TYPE", "text": "a"})
self.assertEqual(observed, [False, True])
self.assertEqual(cua.calls, [])
def test_authority_failure_inside_sender_guard_sends_no_input(self):
gate = {"sender_active": False}
def checker(env):
if gate["sender_active"]:
raise DisplayContextError("IDENTITY_CHANGED")
def sender(text, **kwargs):
gate["sender_active"] = True
kwargs["before_input"]()
cua, _, _, state = self.install(sender=sender, checker=checker)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
self.assertFalse(state["input_started"])
self.assertEqual(cua.calls, [])
def test_post_input_failure_has_do_not_replay_and_no_performed_false(self):
# The injected sender uses the module instance imported by native_host.
def sender(text, **kwargs):
kwargs["before_input"]()
raise text_module.TextError("TYPE_KEYMAP_CHANGED")
cua, _, _, state = self.install(sender=sender)
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "/"})
response = native_host.decorate_response({"executed": False}, state, {})
self.assertTrue(response["input_may_have_been_sent"])
self.assertTrue(response["do_not_replay"])
self.assertNotIn("performed", response)
self.assertEqual(cua.calls, [])
def test_successful_callback_marks_performed_in_existing_response(self):
sender = Mock(side_effect=lambda text, **kwargs: kwargs["before_input"]())
cua, _, _, state = self.install(sender=sender)
cua.handle_cua_message({"action": "TYPE", "text": "/"})
response = native_host.decorate_response({"executed": True}, state, {})
self.assertTrue(response["performed"])
self.assertTrue(response["input_may_have_been_sent"])
self.assertFalse(response["do_not_replay"])
def test_new_message_does_not_reuse_previous_action_context(self):
cua, sender, _, state = self.install()
cua.handle_cua_message({"action": "TYPE", "text": "first"})
native_host.decorate_response({"executed": True}, state, {})
cua.context = {}
with self.assertRaises(CuaTransportError):
cua.handle_cua_message({"action": "TYPE", "text": "second"})
self.assertEqual(sender.call_count, 1)
self.assertEqual(cua.calls, [])
def test_native_snapshot_rejects_held_shift_before_any_input(self):
backend = object.__new__(text_module.NativeBackend)
backend.display = 1
backend.context = 1
backend.modifier_codes = {50}
def keymap(display_id, buffer):
data = bytearray(32)
data[50 // 8] = 1 << (50 % 8)
C.memmove(buffer, bytes(data), 32)
return 1
def active_window(context, result):
result._obj.value = 101
return 0
backend.x11 = SimpleNamespace(XkbGetState=Mock(return_value=0),
XQueryKeymap=keymap, XDefaultScreen=Mock())
backend.xdo = SimpleNamespace(xdo_get_active_window=active_window)
backend.xtst = SimpleNamespace(XTestFakeKeyEvent=Mock())
with self.assertRaisesRegex(text_module.TextError, "^TYPE_HELD_MODIFIERS_UNSUPPORTED$"):
backend.snapshot()
backend.xtst.XTestFakeKeyEvent.assert_not_called()
backend.x11.XDefaultScreen.assert_not_called()
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""XKB action/behavior validation with bounded in-memory public ABI tables."""
import ctypes as C
from pathlib import Path
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import Mock
try:
from framebridge_expanded import x11_text as subject
except ModuleNotFoundError as error:
if error.name != "framebridge_expanded":
raise
import x11_text as subject
from test_x11_text import KeyboardFixture
NO_ACTION = bytes(8)
SHIFT_ACTION = bytes([1, 0, 1, 1, 0, 0, 0, 0])
SLASH = subject.Stroke(61, (), 0, ord("/"))
UPPERCASE_A = subject.Stroke(38, (50,), 1, ord("A"))
class ActionTableFixture:
def __init__(self):
self.symmap = (subject.SymMap * 256)()
self.keyacts = (C.c_ushort * 256)()
self.actions = ((C.c_ubyte * 8) * 8)()
self.behaviors = (C.c_ubyte * 512)()
self.symmap[50].group_info = 1
self.symmap[50].width = 1
self.keyacts[50] = 1
self.actions[1][:] = SHIFT_ACTION
self.symmap[61].group_info = 1
self.symmap[61].width = 1
self.keyacts[61] = 2
self.actions[2][:] = NO_ACTION
self.server = subject.ServerMapPrefix(3, 8, self.actions,
C.addressof(self.behaviors), self.keyacts)
self.client = subject.ClientMapPrefix()
self.client.key_sym_map = self.symmap
self.keyboard = subject.KeyboardPrefix()
self.keyboard.min_key_code = 8
self.keyboard.max_key_code = 61
self.keyboard.server = C.pointer(self.server)
self.keyboard.map = C.pointer(self.client)
self.pointer = C.pointer(self.keyboard)
self.backend = object.__new__(subject.NativeBackend)
self.backend.display = 1
self.backend.modifier_keys = {1: 50}
self.backend.x11 = SimpleNamespace(XkbGetMap=Mock(return_value=self.pointer),
XkbFreeKeyboard=Mock())
def assert_freed(self, case):
self.backend.x11.XkbFreeKeyboard.assert_called_once_with(self.pointer, 0, True)
def validate_text_without_input(self, text):
keyboard = KeyboardFixture()
keyboard.validate_strokes = self.backend.validate_strokes
subject.type_text(text, backend=keyboard, before_input=lambda: None,
clock=lambda: 0, sleep=lambda _: None)
return keyboard
class ActionValidationTests(unittest.TestCase):
def test_us_slash_and_shift_are_safe_and_read_tables_are_freed(self):
fixture = ActionTableFixture()
fixture.backend.validate_strokes((SLASH, UPPERCASE_A))
fixture.backend.x11.XkbGetMap.assert_called_once_with(1, 2 | 16 | 32, 256)
fixture.assert_freed(self)
def test_modifier_map_flag_is_safe_for_exact_shift_mask(self):
subject.validate_actions({38: (NO_ACTION,), 50: (bytes([1, 4, 1, 1, 0, 0, 0, 0]),)},
(UPPERCASE_A,), {1: 50})
def test_group_latch_lock_redirect_and_pointer_actions_reject_before_events(self):
# XKB action types 2=LATCH_MODS, 3=LOCK_MODS, 4=SET_GROUP,
# 5=LATCH_GROUP, 6=LOCK_GROUP, 7=MOVE_PTR, 17=REDIRECT_KEY.
for action_type in (2, 3, 4, 5, 6, 7, 17):
with self.subTest(action_type=action_type):
fixture = ActionTableFixture()
fixture.actions[2][0] = action_type
keyboard = KeyboardFixture()
keyboard.validate_strokes = fixture.backend.validate_strokes
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
subject.type_text("/", backend=keyboard, before_input=lambda: None,
clock=lambda: 0, sleep=lambda _: None)
self.assertEqual(keyboard.events, [])
fixture.assert_freed(self)
def test_character_key_may_not_set_modifiers(self):
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
subject.validate_actions({61: (SHIFT_ACTION,)}, (SLASH,), {1: 50})
def test_modifier_action_requires_exact_mask_and_no_extra_effects(self):
actions = [bytes([1, 0, 4, 4, 0, 0, 0, 0]),
bytes([1, 2, 1, 1, 0, 0, 0, 0]),
bytes([1, 0, 1, 1, 1, 0, 0, 0]),
bytes([1, 0, 1, 1, 0, 1, 0, 0]), NO_ACTION]
for action in actions:
with self.subTest(action=action):
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
subject.validate_actions({38: (NO_ACTION,), 50: (action,)},
(UPPERCASE_A,), {1: 50})
def test_clearlocks_flags_one_and_five_preserve_unrelated_numlock(self):
for flags in (1, 5):
with self.subTest(flags=flags):
fixture = ActionTableFixture()
fixture.actions[1][1] = flags
keyboard = KeyboardFixture(locked=16)
keyboard.validate_strokes = fixture.backend.validate_strokes
subject.type_text("A/", backend=keyboard, before_input=lambda: None,
clock=lambda: 0, sleep=lambda _: None)
self.assertEqual("".join(keyboard.output), "A/")
self.assertEqual(keyboard.initial.locked_mods, 16)
self.assertNotIn(77, [code for code, _ in keyboard.events])
def test_clearlocks_cannot_clear_an_existing_lock_on_action_modifier(self):
for flags in (1, 5):
with self.subTest(flags=flags):
fixture = ActionTableFixture()
fixture.actions[1][1] = flags
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_WOULD_CLEAR_LOCK$"):
fixture.backend.validate_strokes((UPPERCASE_A,), locked_mods=1)
fixture.assert_freed(self)
def test_action_missing_or_wrong_width_fails_closed(self):
for actions, error in [({}, "TYPE_KEY_ACTIONS_UNAVAILABLE"),
({61: (bytes(7),)}, "TYPE_KEY_ACTIONS_INVALID")]:
with self.subTest(actions=actions):
with self.assertRaisesRegex(subject.TextError, "^" + error + "$"):
subject.validate_actions(actions, (SLASH,), {1: 50})
def test_all_groups_and_levels_are_checked_not_just_first_action(self):
fixture = ActionTableFixture()
fixture.symmap[61].group_info = 2
fixture.symmap[61].width = 2
fixture.server.num_acts = 6
fixture.actions[5][0] = 6 # Unsafe fourth entry, reached by bounded read.
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTION_UNSUPPORTED$"):
fixture.backend.validate_strokes((SLASH,))
fixture.assert_freed(self)
def test_planned_character_or_modifier_nondefault_behavior_is_rejected(self):
for code in (38, 50, 61):
for behavior_byte in (0, 1):
with self.subTest(code=code, behavior_byte=behavior_byte):
fixture = ActionTableFixture()
fixture.behaviors[2 * code + behavior_byte] = 1
keyboard = KeyboardFixture()
keyboard.validate_strokes = fixture.backend.validate_strokes
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_BEHAVIOR_UNSUPPORTED$"):
subject.type_text("A/", backend=keyboard, before_input=lambda: None,
clock=lambda: 0, sleep=lambda _: None)
self.assertEqual(keyboard.events, [])
fixture.assert_freed(self)
def test_unplanned_behavior_does_not_reject_safe_plan(self):
fixture = ActionTableFixture()
fixture.behaviors[2 * 40] = 1
fixture.backend.validate_strokes((SLASH,))
fixture.assert_freed(self)
def test_zero_action_offsets_and_missing_optional_tables_default_to_no_action(self):
fixture = ActionTableFixture()
fixture.server.key_acts = C.POINTER(C.c_ushort)()
fixture.server.acts = C.POINTER(C.c_ubyte * 8)()
fixture.server.num_acts = 0
fixture.server.behaviors = None
actions = fixture.backend._actions()
self.assertEqual(actions[61], (NO_ACTION,))
self.assertEqual(fixture.backend.behaviors[61], bytes(2))
subject.validate_actions(actions, (SLASH,), {1: 50})
fixture.assert_freed(self)
def test_bad_action_offset_or_span_is_rejected_and_freed(self):
cases = [(3, 1, 1), (2, 2, 1), (1, 0, 1), (65535, 1, 1)]
for offset, width, groups in cases:
with self.subTest(offset=offset, width=width, groups=groups):
fixture = ActionTableFixture()
fixture.keyacts[61] = offset
fixture.symmap[61].width = width
fixture.symmap[61].group_info = groups
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
fixture.backend._actions()
fixture.assert_freed(self)
def test_invalid_dimensions_are_rejected_before_table_read_and_freed(self):
for attribute, value in [("group_info", 5), ("width", 33)]:
with self.subTest(attribute=attribute):
fixture = ActionTableFixture()
setattr(fixture.symmap[61], attribute, value)
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
fixture.backend._actions()
fixture.assert_freed(self)
def test_invalid_keyboard_range_or_required_pointer_is_rejected_and_freed(self):
for fault in ("range", "server", "client", "symmap", "actions", "count"):
with self.subTest(fault=fault):
fixture = ActionTableFixture()
if fault == "range":
fixture.keyboard.min_key_code = 7
elif fault == "server":
fixture.keyboard.server = C.POINTER(subject.ServerMapPrefix)()
elif fault == "client":
fixture.keyboard.map = C.POINTER(subject.ClientMapPrefix)()
elif fault == "symmap":
fixture.client.key_sym_map = C.POINTER(subject.SymMap)()
elif fault == "actions":
fixture.server.acts = C.POINTER(C.c_ubyte * 8)()
else:
fixture.server.num_acts = 9
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_INVALID$"):
fixture.backend._actions()
fixture.assert_freed(self)
def test_null_keyboard_pointer_reports_unavailable_without_freeing_null(self):
fixture = ActionTableFixture()
fixture.backend.x11.XkbGetMap.return_value = C.POINTER(subject.KeyboardPrefix)()
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEY_ACTIONS_UNAVAILABLE$"):
fixture.backend._actions()
fixture.backend.x11.XkbFreeKeyboard.assert_not_called()
def test_public_xkb_prefix_abi_offsets(self):
self.assertEqual(C.sizeof(subject.SymMap), 8)
self.assertEqual(subject.SymMap.offset.offset, 6)
self.assertEqual(subject.ClientMapPrefix.key_sym_map.offset, 32)
self.assertEqual(subject.ServerMapPrefix.acts.offset, 8)
self.assertEqual(subject.ServerMapPrefix.behaviors.offset, 16)
self.assertEqual(subject.ServerMapPrefix.key_acts.offset, 24)
self.assertEqual(subject.KeyboardPrefix.server.offset, 24)
self.assertEqual(subject.KeyboardPrefix.map.offset, 32)
if __name__ == "__main__":
unittest.main()
+360
View File
@@ -0,0 +1,360 @@
"""Behavioral X11 text regressions; no display, libraries, or input devices.
The fixture is an independent US physical-key model. It deliberately places
the earlier LATAM slash before the US slash, matching the ASUS failure class.
"""
import ast
import ctypes as C
from dataclasses import replace
import inspect
from pathlib import Path
import sys
from types import SimpleNamespace
import textwrap
import unittest
from unittest.mock import Mock, patch
try:
from framebridge_expanded import x11_text as subject
except ModuleNotFoundError as error:
if error.name != "framebridge_expanded":
raise
import x11_text as subject
SHIFT, CAPS, NUM = 1, 2, 16
class KeyboardFixture:
def __init__(self, *, held=(), locked=0):
# Physical X11 evdev codes and the US unshifted/shifted characters.
rows = [
(10, "1", "!"), (11, "2", "@"), (12, "3", "#"),
(13, "4", "$"), (14, "5", "%"), (15, "6", "^"),
(16, "7", "&"), (17, "8", "*"), (18, "9", "("),
(19, "0", ")"), (20, "-", "_"), (21, "=", "+"),
(34, "[", "{"), (35, "]", "}"), (47, ";", ":"),
(48, "'", '"'), (49, "`", "~"), (51, "\\", "|"),
(59, ",", "<"), (60, ".", ">"), (61, "/", "?"),
(65, " ", " "),
]
for start, letters in [(24, "qwertyuiop"), (38, "asdfghjkl"), (52, "zxcvbnm")]:
rows.extend((start + index, letter, letter.upper())
for index, letter in enumerate(letters))
self.physical = {code: (lower, upper) for code, lower, upper in rows}
self.modifier_keys = {SHIFT: 50}
mappings = [subject.Mapping(16, 1, ord("/"), SHIFT, ord("/"))]
for code, lower, upper in rows:
mappings.append(subject.Mapping(code, 0, ord(lower), 0, ord(lower)))
if lower != upper:
mappings.append(subject.Mapping(code, 0, ord(upper), SHIFT, ord(upper)))
self.mappings = tuple(mappings)
self.pressed = set(held)
self.initial = subject.Snapshot(0, 0, 0, 0, locked, 0, 0, locked,
101, 1920, 1080, "fixture-map-v1", tuple(sorted(held)))
self.events = []
self.output = []
self.snapshot_calls = 0
self.sync_calls = 0
self.sleeps = 0
self.on_snapshot = None
self.on_sleep = None
self.fail_events = {}
self.closed = False
def lookup(self, code, mask, group):
if group == 1 and code == 16:
return ord("/") if mask & SHIFT else ord("7")
if group != 0 or code not in self.physical:
return 0
lower, upper = self.physical[code]
shifted = bool(mask & SHIFT)
if lower.isalpha():
shifted ^= bool(mask & CAPS)
return ord(upper if shifted else lower)
def snapshot(self):
self.snapshot_calls += 1
mods = self.initial.locked_mods
if self.pressed & {50, 62}:
mods |= SHIFT
if 37 in self.pressed:
mods |= 4
value = replace(self.initial, mods=mods,
base_mods=mods & ~self.initial.locked_mods,
held=tuple(sorted(self.pressed & {37, 50, 62})))
return self.on_snapshot(value, self.snapshot_calls) if self.on_snapshot else value
def event(self, code, pressed):
self.events.append((code, pressed))
if pressed:
self.pressed.add(code)
else:
self.pressed.discard(code)
if pressed and code in self.physical:
mask = self.initial.locked_mods | (SHIFT if self.pressed & {50, 62} else 0)
value = self.lookup(code, mask, self.initial.group)
self.output.append(chr(value) if value else "\ufffd")
error = self.fail_events.get(len(self.events))
if error:
raise error
def sync(self):
self.sync_calls += 1
def sleep(self, duration):
self.sleeps += 1
if self.on_sleep:
self.on_sleep(self.sleeps)
def close(self):
self.closed = True
class TextBehaviorTests(unittest.TestCase):
def run_text(self, text, keyboard=None, guard=None, **kwargs):
keyboard = keyboard or KeyboardFixture()
subject.type_text(text, backend=keyboard, before_input=guard or (lambda: None),
clock=lambda: 0, sleep=keyboard.sleep, **kwargs)
return keyboard
def assert_error(self, code, operation):
with self.assertRaises(subject.TextError) as caught:
operation()
self.assertEqual(str(caught.exception), code)
def test_slash_uses_current_us_key_even_with_earlier_latam_entry(self):
keyboard = self.run_text("/")
self.assertEqual(keyboard.events, [(61, True), (61, False)])
self.assertEqual("".join(keyboard.output), "/")
def test_original_shell_looking_text_is_only_exact_data(self):
text = "exec tmux -S /run/user/1000/fbe-tty-probe/sudo-consoles/sudo/s-c451.sock attach-session -t fb-real-f86"
keyboard = self.run_text(text)
self.assertEqual("".join(keyboard.output), text)
self.assertEqual(sum(event == (61, True) for event in keyboard.events), text.count("/"))
def test_unsupported_last_character_does_not_even_release_held_modifiers(self):
keyboard = KeyboardFixture(held=(37, 50))
self.assert_error("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP",
lambda: self.run_text("already representable/\U0001f642", keyboard))
self.assertEqual(keyboard.events, [])
self.assertEqual(keyboard.pressed, {37, 50})
def test_nonzero_group_or_group_latch_is_rejected_before_events(self):
cases = [dict(group=1, locked_group=1), dict(group=2, locked_group=2),
dict(base_group=1), dict(latched_group=1), dict(locked_group=1)]
for changed in cases:
with self.subTest(changed=changed):
keyboard = KeyboardFixture(held=(50,))
keyboard.initial = replace(keyboard.initial, **changed)
self.assert_error("TYPE_KEYBOARD_GROUP_UNSUPPORTED", lambda: self.run_text("a", keyboard))
self.assertEqual(keyboard.events, [])
def test_latched_modifier_rejected_without_consuming_it(self):
keyboard = KeyboardFixture()
keyboard.initial = replace(keyboard.initial, latched_mods=SHIFT)
self.assert_error("TYPE_LATCHED_MODIFIERS_UNSUPPORTED", lambda: self.run_text("A", keyboard))
self.assertEqual(keyboard.events, [])
def test_locked_control_alt_or_super_rejected_before_any_event(self):
for mask in (4, 8, 64):
with self.subTest(mask=mask):
keyboard = KeyboardFixture(locked=mask)
self.assert_error("TYPE_LOCKED_MODIFIERS_UNSUPPORTED",
lambda: self.run_text("a/", keyboard))
self.assertEqual(keyboard.events, [])
self.assertEqual(keyboard.initial.locked_mods, mask)
def test_mid_text_focus_change_stops_after_prefix_without_replay(self):
keyboard = KeyboardFixture()
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, window=999))
self.assert_error("TYPE_ACTIVE_WINDOW_CHANGED", lambda: self.run_text("abc", keyboard))
self.assertEqual(keyboard.events, [(38, True), (38, False)])
self.assertEqual("".join(keyboard.output), "a")
def test_mid_text_group_change_stops_and_marks_restoration_uncertain(self):
keyboard = KeyboardFixture()
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, group=1))
self.assert_error("TYPE_MODIFIER_RESTORE_UNCERTAIN", lambda: self.run_text("ab", keyboard))
self.assertEqual(keyboard.events, [(38, True), (38, False)])
def test_mid_text_keymap_change_stops_after_prefix(self):
keyboard = KeyboardFixture()
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, keymap="changed"))
self.assert_error("TYPE_KEYMAP_CHANGED", lambda: self.run_text("ab", keyboard))
self.assertEqual("".join(keyboard.output), "a")
self.assertEqual(keyboard.events, [(38, True), (38, False)])
def test_mid_text_geometry_change_stops_after_prefix(self):
keyboard = KeyboardFixture()
keyboard.on_sleep = lambda count: setattr(keyboard, "initial", replace(keyboard.initial, width=800))
self.assert_error("TYPE_GEOMETRY_CHANGED", lambda: self.run_text("ab", keyboard))
self.assertEqual("".join(keyboard.output), "a")
def test_guard_failure_before_first_mutation_has_zero_events(self):
keyboard = KeyboardFixture(held=(37,))
guard = Mock(side_effect=subject.TextError("AUTHORITY_EXPIRED"))
self.assert_error("AUTHORITY_EXPIRED", lambda: self.run_text("a", keyboard, guard))
self.assertEqual(keyboard.events, [])
guard.assert_called_once_with()
def test_guard_failure_mid_text_retains_prefix_without_replay(self):
keyboard = KeyboardFixture()
guard = Mock(side_effect=[None, subject.TextError("AUTHORITY_EXPIRED")])
self.assert_error("AUTHORITY_EXPIRED", lambda: self.run_text("ab", keyboard, guard))
self.assertEqual(keyboard.events, [(38, True), (38, False)])
self.assertEqual("".join(keyboard.output), "a")
def test_focus_change_during_permission_wait_emits_zero_events(self):
keyboard = KeyboardFixture()
def guard():
keyboard.initial = replace(keyboard.initial, window=999)
permission = Mock(side_effect=guard)
self.assert_error("TYPE_ACTIVE_WINDOW_CHANGED", lambda: self.run_text("/", keyboard, permission))
permission.assert_called_once_with()
self.assertEqual(keyboard.events, [])
def test_keymap_change_during_permission_wait_emits_zero_events(self):
keyboard = KeyboardFixture()
def guard():
keyboard.initial = replace(keyboard.initial, keymap="changed-during-ack")
permission = Mock(side_effect=guard)
self.assert_error("TYPE_KEYMAP_CHANGED", lambda: self.run_text("/", keyboard, permission))
permission.assert_called_once_with()
self.assertEqual(keyboard.events, [])
def test_failed_keydown_is_released_and_held_modifiers_restored(self):
keyboard = KeyboardFixture(held=(37,))
keyboard.fail_events[3] = subject.TextError("EVENT_FAILED")
self.assert_error("EVENT_FAILED", lambda: self.run_text("A", keyboard))
self.assertEqual(keyboard.events, [(37, False), (50, True), (38, True),
(38, False), (50, False), (37, True)])
self.assertEqual(keyboard.pressed, {37})
def test_cleanup_continues_after_one_cleanup_event_fails(self):
keyboard = KeyboardFixture(held=(37, 62))
keyboard.fail_events[4] = subject.TextError("KEYDOWN_FAILED")
keyboard.fail_events[5] = subject.TextError("RELEASE_FAILED")
self.assert_error("TYPE_MODIFIER_RESTORE_UNCERTAIN", lambda: self.run_text("A", keyboard))
self.assertEqual(keyboard.events[-4:], [(38, False), (50, False), (37, True), (62, True)])
self.assertEqual(keyboard.pressed, {37, 62})
def test_partial_modifier_clear_is_restored_after_event_failure(self):
keyboard = KeyboardFixture(held=(37, 50))
keyboard.fail_events[1] = subject.TextError("CLEAR_FAILED")
self.assert_error("CLEAR_FAILED", lambda: self.run_text("a", keyboard))
self.assertEqual(keyboard.events, [(37, False), (37, True)])
self.assertEqual(keyboard.pressed, {37, 50})
def test_caps_lock_uses_lookup_result_without_toggling_lock(self):
keyboard = self.run_text("aA/", KeyboardFixture(locked=CAPS))
self.assertEqual("".join(keyboard.output), "aA/")
self.assertEqual(keyboard.events, [(50, True), (38, True), (38, False),
(50, False), (38, True), (38, False),
(61, True), (61, False)])
self.assertEqual(keyboard.initial.locked_mods, CAPS)
def test_num_lock_is_preserved_and_does_not_add_numlock_events(self):
keyboard = self.run_text("1/A", KeyboardFixture(locked=NUM))
self.assertEqual("".join(keyboard.output), "1/A")
self.assertNotIn(77, [code for code, _ in keyboard.events])
self.assertEqual(keyboard.initial.locked_mods, NUM)
def test_held_modifiers_clear_once_and_restore_after_success(self):
keyboard = self.run_text("a", KeyboardFixture(held=(37, 62)))
self.assertEqual(keyboard.events, [(37, False), (62, False), (38, True),
(38, False), (37, True), (62, True)])
self.assertEqual(keyboard.pressed, {37, 62})
def test_too_small_budget_rejects_before_events(self):
keyboard = KeyboardFixture(held=(37,))
self.assert_error("TYPE_BUDGET_TOO_SMALL", lambda: self.run_text("abc", keyboard, timeout=.02))
self.assertEqual(keyboard.events, [])
def test_expected_window_or_geometry_mismatch_has_zero_events(self):
for kwargs, code in [({"expected_window": 202}, "TYPE_ACTIVE_WINDOW_CHANGED"),
({"expected_geometry": (800, 600)}, "TYPE_GEOMETRY_CHANGED")]:
with self.subTest(kwargs=kwargs):
keyboard = KeyboardFixture()
self.assert_error(code, lambda: self.run_text("a", keyboard, **kwargs))
self.assertEqual(keyboard.events, [])
def test_layout_binding_required_entry_never_becomes_typeable(self):
keyboard = KeyboardFixture()
keyboard.mappings += (subject.Mapping(120, 0, 0x01002603, 0, 0x2603, 1),)
keyboard.physical[120] = ("\u2603", "\u2603")
self.assert_error("TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP",
lambda: self.run_text("\u2603", keyboard))
self.assertEqual(keyboard.events, [])
class NativeBoundaryTests(unittest.TestCase):
def test_primary_header_abi_sizes_and_offsets(self):
self.assertEqual(C.sizeof(subject.CharCode), 32)
self.assertEqual({name: getattr(subject.CharCode, name).offset for name, _ in subject.CharCode._fields_},
{"key": 0, "code": 4, "symbol": 8, "group": 16,
"modmask": 20, "needs_binding": 24})
self.assertEqual(C.sizeof(subject.XkbState), 18)
self.assertEqual({name: getattr(subject.XkbState, name).offset for name, _ in subject.XkbState._fields_},
{"group": 0, "locked_group": 1, "base_group": 2,
"latched_group": 4, "mods": 6, "base_mods": 7,
"latched_mods": 8, "locked_mods": 9, "compat_state": 10,
"grab_mods": 11, "compat_grab_mods": 12, "lookup_mods": 13,
"compat_lookup_mods": 14, "ptr_buttons": 16})
self.assertEqual(subject.XdoPrefix.charcodes.offset, 16)
self.assertEqual(subject.XdoPrefix.charcodes_len.offset, 24)
self.assertEqual([name for name, _ in subject.XdoPrefix._fields_],
["xdpy", "display_name", "charcodes", "charcodes_len"])
def test_only_native_input_binding_is_xtest_key_event(self):
tree = ast.parse(textwrap.dedent(inspect.getsource(subject.NativeBackend._bind)))
bindings = {call.args[1].value for call in ast.walk(tree)
if isinstance(call, ast.Call) and isinstance(call.func, ast.Name)
and call.func.id == "bind"}
self.assertEqual(bindings & {"xdo_enter_text_window", "xdo_send_keysequence_window",
"XkbLockGroup", "XkbLatchGroup", "XChangeKeyboardMapping",
"XkbSetMap", "XSetSelectionOwner", "XSendEvent"}, set())
self.assertIn("XTestFakeKeyEvent", bindings)
self.assertEqual({name for name in bindings if name.startswith("xdo_")},
{"xdo_version", "xdo_new", "xdo_free", "xdo_get_active_window"})
def test_unknown_libxdo_abi_rejected_without_context_or_display(self):
fake = SimpleNamespace(xdo_version=Mock(return_value=b"4.20260303.1"), xdo_new=Mock())
with patch.object(subject, "trusted_library", return_value=fake), patch.object(subject.NativeBackend, "_bind"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_ABI_UNSUPPORTED$"):
subject.NativeBackend()
fake.xdo_new.assert_not_called()
def test_untrusted_library_ancestor_is_rejected_before_load(self):
for uid, mode in [(1000, 0o755), (0, 0o777), (0, 0o775)]:
with self.subTest(uid=uid, mode=mode):
leaf = Mock()
leaf.lstat.return_value = SimpleNamespace(st_uid=0, st_mode=0o755)
ancestor = Mock()
ancestor.lstat.return_value = SimpleNamespace(st_uid=uid, st_mode=mode)
leaf.parents = [ancestor]
fake_path = Mock()
fake_path.__truediv__ = Mock(return_value=fake_path)
fake_path.resolve.return_value = leaf
with patch.object(subject, "Path", return_value=fake_path), patch.object(subject.C, "CDLL") as loader:
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_LIBRARY_UNTRUSTED$"):
subject.trusted_library("libX11.so.6")
loader.assert_not_called()
def test_missing_library_is_rejected_without_loader(self):
fake_path = Mock()
fake_path.__truediv__ = Mock(return_value=fake_path)
fake_path.resolve.side_effect = FileNotFoundError("fixture missing")
with patch.object(subject, "Path", return_value=fake_path), patch.object(subject.C, "CDLL") as loader:
with self.assertRaisesRegex(subject.TextError, "^TYPE_NATIVE_LIBRARY_UNAVAILABLE$"):
subject.trusted_library("libX11.so.6")
loader.assert_not_called()
if __name__ == "__main__":
unittest.main()
+237
View File
@@ -0,0 +1,237 @@
"""Exercise bounded worker pipes with real Python-only child processes.
The worker command is replaced at the Popen boundary. No native backend or
display is instantiated; request/permission/deadline behavior uses real pipes.
"""
from contextlib import contextmanager
import json
import os
from pathlib import Path
import subprocess
import sys
import time
import unittest
from unittest.mock import Mock, patch
try:
from framebridge_expanded import x11_text as subject
except ModuleNotFoundError as error:
if error.name != "framebridge_expanded":
raise
import x11_text as subject
STUB = r'''
import json, signal, sys, time
mode = sys.argv[1]
if mode == "hang_unread":
time.sleep(10)
raise SystemExit(2)
request = json.loads(sys.stdin.buffer.readline())
if request["window"] != 101 or request["geometry"] != [1920,1080]:
print("ERROR TYPE_FIXTURE_CONTEXT_INVALID", flush=True)
raise SystemExit(2)
if mode == "preflight":
print("ERROR TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP", flush=True)
elif mode == "exit":
raise SystemExit(2)
elif mode == "bad":
print("NOT A VALID MESSAGE", flush=True)
elif mode == "oversize":
print("x" * 5000, flush=True)
elif mode == "hang_before":
time.sleep(10)
else:
for index in range(2 if mode == "twice" else 1):
print("INPUT", flush=True)
if sys.stdin.buffer.readline() != b"GO\n":
raise SystemExit(3)
if mode == "hang_after":
time.sleep(10)
elif mode == "ignore_terminate":
signal.signal(signal.SIGTERM, signal.SIG_IGN)
time.sleep(10)
elif mode == "error_after":
print("ERROR TYPE_KEYMAP_CHANGED", flush=True)
else:
print("OK", flush=True)
'''
class BoundedWorkerTests(unittest.TestCase):
def test_selector_failure_does_not_spawn_worker(self):
with patch.object(subject.selectors, "DefaultSelector", side_effect=OSError("fixture")), \
patch.object(subject.subprocess, "Popen") as spawn:
with self.assertRaises(OSError):
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
before_input=lambda: None)
spawn.assert_not_called()
def test_spawn_failure_closes_selector(self):
selector = Mock()
with patch.object(subject.selectors, "DefaultSelector", return_value=selector), \
patch.object(subject.subprocess, "Popen", side_effect=OSError("fixture")):
with self.assertRaises(OSError):
subject.bounded_type_text("/", expected_window=101, expected_geometry=(1920, 1080),
before_input=lambda: None)
selector.close.assert_called_once_with()
@contextmanager
def child(self, mode):
real_popen = subprocess.Popen
children = []
def spawn(argv, **kwargs):
self.assertEqual(argv, [sys.executable, "-I", "-S", "-B", str(Path(subject.__file__).resolve()), "--worker"])
child = real_popen([sys.executable, "-u", "-c", STUB, mode], **kwargs)
children.append(child)
return child
try:
with patch.object(subject.subprocess, "Popen", side_effect=spawn):
yield children
finally:
for process in children:
if process.poll() is None:
process.kill()
process.wait(timeout=2)
self.assertIsNotNone(process.returncode)
self.assertTrue(process.stdin.closed)
self.assertTrue(process.stdout.closed)
def invoke(self, guard, *, text="-S /run/fixture", timeout=.5):
return subject.bounded_type_text(text, expected_window=101,
expected_geometry=(1920, 1080),
before_input=guard, timeout=timeout)
def test_preflight_failure_does_not_request_any_permission(self):
guard = Mock()
with self.child("preflight"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_TEXT_UNREPRESENTABLE_IN_CURRENT_GROUP$"):
self.invoke(guard)
guard.assert_not_called()
def test_one_input_batch_requires_one_guard_and_ack(self):
guard = Mock()
with self.child("success"):
self.assertIsNone(self.invoke(guard))
guard.assert_called_once_with()
def test_each_input_batch_requires_a_fresh_guard(self):
guard = Mock()
with self.child("twice"):
self.invoke(guard)
self.assertEqual(guard.call_count, 2)
def test_timeout_before_permission_has_zero_guard_calls(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_before"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_not_called()
def test_timeout_after_permission_retains_possible_input_evidence(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_after"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
def test_guard_failure_terminates_child_waiting_for_ack(self):
guard = Mock(side_effect=RuntimeError("AUTHORITY_EXPIRED"))
started = time.monotonic()
with self.child("success"):
with self.assertRaisesRegex(RuntimeError, "^AUTHORITY_EXPIRED$"):
self.invoke(guard)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
def test_stalled_child_that_ignores_termination_is_killed(self):
guard = Mock()
started = time.monotonic()
with self.child("ignore_terminate") as children:
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_called_once_with()
self.assertLess(children[0].returncode, 0)
def test_partial_input_error_preserves_permission_evidence(self):
guard = Mock()
with self.child("error_after"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_KEYMAP_CHANGED$"):
self.invoke(guard)
guard.assert_called_once_with()
def test_unknown_protocol_line_fails_without_permission(self):
guard = Mock()
with self.child("bad"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
self.invoke(guard)
guard.assert_not_called()
def test_oversized_worker_line_fails_without_permission(self):
guard = Mock()
with self.child("oversize"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_PROTOCOL_INVALID$"):
self.invoke(guard)
guard.assert_not_called()
def test_worker_exit_is_not_mistaken_for_success(self):
guard = Mock()
with self.child("exit"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_EXIT_UNCERTAIN$"):
self.invoke(guard)
guard.assert_not_called()
def test_large_unicode_request_cannot_block_on_unread_pipe(self):
guard = Mock()
started = time.monotonic()
with self.child("hang_unread"):
with self.assertRaisesRegex(subject.TextError, "^TYPE_WORKER_TIMEOUT$"):
self.invoke(guard, text="\U0001f642" * 4096, timeout=.15)
self.assertLess(time.monotonic() - started, 2)
guard.assert_not_called()
def test_main_dispatch_runs_only_after_backend_definition(self):
# Replace only the NativeBackend AST in memory. This executes the real
# module/worker order with a constructor that cannot call native code.
wrapper = r'''
import ast, pathlib, sys
source = pathlib.Path(sys.argv[1]).read_text()
tree = ast.parse(source)
replacement = ast.parse("class NativeBackend:\n def __init__(self):\n raise TextError('TYPE_FIXTURE_NATIVE_BOUNDARY')\n").body[0]
for index, node in enumerate(tree.body):
if isinstance(node, ast.ClassDef) and node.name == 'NativeBackend':
tree.body[index] = replacement
ast.fix_missing_locations(tree)
sys.argv = [sys.argv[1], '--worker']
namespace = {'__name__': '__main__', '__file__': sys.argv[0]}
exec(compile(tree, sys.argv[0], 'exec'), namespace)
'''
request = json.dumps({"text": "a", "window": 101,
"geometry": [1920, 1080], "timeout": 1}) + "\n"
result = subprocess.run([sys.executable, "-c", wrapper, str(Path(subject.__file__))],
input=request, text=True, capture_output=True, timeout=2)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, "ERROR TYPE_FIXTURE_NATIVE_BOUNDARY\n")
def test_real_isolated_worker_bootstrap_reaches_safe_display_preflight(self):
guard = Mock()
# This display number cannot refer to the user's real session. Native
# preflight can read libraries but cannot emit a GUI event.
with patch.dict(os.environ, {"DISPLAY": ":99999"}):
with self.assertRaises(subject.TextError) as caught:
self.invoke(guard, timeout=1)
self.assertIn(str(caught.exception), {"TYPE_DISPLAY_UNAVAILABLE",
"TYPE_NATIVE_LIBRARY_UNAVAILABLE",
"TYPE_NATIVE_ABI_UNSUPPORTED"})
guard.assert_not_called()
if __name__ == "__main__":
unittest.main()