Fix guarded X11 text mapping without group switching
This commit is contained in:
1 parent
7a27f7fd55
commit
b283999f5a
7 files changed
+1857
-4
No files matched your search
@@ -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({})
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user