238 lines
11 KiB
Python
238 lines
11 KiB
Python
"""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()
|