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
@@ -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()
|
||||
Reference in new issue
Block a user