Files

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()