gtk not running as root anymore
parent
3c97fd34b4
commit
5e4ae668dd
@ -1,4 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# This binary exists only to make key-mapper.desktop compatible to
|
|
||||||
# environments that can't handle arguments in Exec.
|
|
||||||
pkexec key-mapper-gtk
|
|
@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2020 sezanzeb <proxima@sezanzeb.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Starts the root helper."""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import atexit
|
||||||
|
import signal
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
from keymapper.logger import update_verbosity
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument(
|
||||||
|
'-d', '--debug', action='store_true', dest='debug',
|
||||||
|
help='Displays additional debug information', default=False
|
||||||
|
)
|
||||||
|
|
||||||
|
options = parser.parse_args(sys.argv[1:])
|
||||||
|
|
||||||
|
update_verbosity(options.debug)
|
||||||
|
|
||||||
|
# import key-mapper stuff after setting the log verbosity
|
||||||
|
from keymapper.gui.helper import RootHelper
|
||||||
|
|
||||||
|
def on_exit():
|
||||||
|
"""Don't remain idle and alive when the GUI exits via ctrl+c."""
|
||||||
|
# makes no sense to me, but after the keyboard interrupt it is still
|
||||||
|
# waiting for an event to complete (`S` in `ps ax`), even when using
|
||||||
|
# sys.exit
|
||||||
|
os.kill(os.getpid(), signal.SIGKILL)
|
||||||
|
|
||||||
|
atexit.register(on_exit)
|
||||||
|
|
||||||
|
helper = RootHelper()
|
||||||
|
helper.run()
|
@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2021 sezanzeb <proxima@hip70890b.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Process that sends stuff to the GUI.
|
||||||
|
|
||||||
|
It should be started via key-mapper-control and pkexec.
|
||||||
|
|
||||||
|
GUIs should not run as root
|
||||||
|
https://wiki.archlinux.org/index.php/Running_GUI_applications_as_root
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import select
|
||||||
|
import multiprocessing
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import evdev
|
||||||
|
from evdev.ecodes import EV_KEY
|
||||||
|
|
||||||
|
from keymapper.ipc.pipe import Pipe
|
||||||
|
from keymapper.logger import logger
|
||||||
|
from keymapper.state import custom_mapping
|
||||||
|
from keymapper.getdevices import get_devices, is_gamepad
|
||||||
|
from keymapper import utils
|
||||||
|
|
||||||
|
|
||||||
|
TERMINATE = 'terminate'
|
||||||
|
|
||||||
|
|
||||||
|
def is_helper_running():
|
||||||
|
"""Check if the helper is running."""
|
||||||
|
try:
|
||||||
|
subprocess.check_output(['pgrep', '-f', 'key-mapper-helper'])
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class RootHelper:
|
||||||
|
"""Client that runs as root and works for the GUI.
|
||||||
|
|
||||||
|
Sends device information and keycodes to the GUIs socket.
|
||||||
|
|
||||||
|
Commands are either numbers for generic commands,
|
||||||
|
or strings to start listening on a specific device.
|
||||||
|
"""
|
||||||
|
def __init__(self):
|
||||||
|
"""Construct the helper and initialize its sockets."""
|
||||||
|
self._results = Pipe('/tmp/key-mapper/results')
|
||||||
|
self._commands = Pipe('/tmp/key-mapper/commands')
|
||||||
|
|
||||||
|
# the ui needs the devices first
|
||||||
|
self._results.send({
|
||||||
|
'type': 'devices',
|
||||||
|
'message': get_devices()
|
||||||
|
})
|
||||||
|
|
||||||
|
self.device_name = None
|
||||||
|
self._pipe = multiprocessing.Pipe()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Start doing stuff. Blocks."""
|
||||||
|
while True:
|
||||||
|
self._handle_commands()
|
||||||
|
self._start_reading()
|
||||||
|
|
||||||
|
def _handle_commands(self):
|
||||||
|
"""Handle all unread commands."""
|
||||||
|
# wait for something to do
|
||||||
|
select.select([self._commands], [], [])
|
||||||
|
|
||||||
|
while self._commands.poll():
|
||||||
|
cmd = self._commands.recv()
|
||||||
|
logger.debug('Received command "%s"', cmd)
|
||||||
|
if cmd == TERMINATE:
|
||||||
|
logger.debug('Helper terminates')
|
||||||
|
sys.exit(0)
|
||||||
|
elif cmd in get_devices():
|
||||||
|
self.device_name = cmd
|
||||||
|
else:
|
||||||
|
logger.error('Received unknown command "%s"', cmd)
|
||||||
|
|
||||||
|
def _start_reading(self):
|
||||||
|
"""Tell the evdev lib to start looking for keycodes.
|
||||||
|
|
||||||
|
If read is called without prior start_reading, no keycodes
|
||||||
|
will be available.
|
||||||
|
|
||||||
|
This blocks forever until it discovers a new command on the socket.
|
||||||
|
"""
|
||||||
|
device_name = self.device_name
|
||||||
|
|
||||||
|
rlist = {}
|
||||||
|
gamepad = {}
|
||||||
|
|
||||||
|
if device_name is None:
|
||||||
|
logger.error('device_name is None')
|
||||||
|
return
|
||||||
|
|
||||||
|
group = get_devices()[device_name]
|
||||||
|
virtual_devices = []
|
||||||
|
# Watch over each one of the potentially multiple devices per
|
||||||
|
# hardware
|
||||||
|
for path in group['paths']:
|
||||||
|
try:
|
||||||
|
device = evdev.InputDevice(path)
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if evdev.ecodes.EV_KEY in device.capabilities():
|
||||||
|
virtual_devices.append(device)
|
||||||
|
|
||||||
|
if len(virtual_devices) == 0:
|
||||||
|
logger.debug('No interesting device for "%s"', device_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
for device in virtual_devices:
|
||||||
|
rlist[device.fd] = device
|
||||||
|
gamepad[device.fd] = is_gamepad(device)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Starting reading keycodes from "%s"',
|
||||||
|
'", "'.join([device.name for device in virtual_devices])
|
||||||
|
)
|
||||||
|
|
||||||
|
rlist[self._commands] = self._commands
|
||||||
|
|
||||||
|
while True:
|
||||||
|
ready_fds = select.select(rlist, [], [])
|
||||||
|
if len(ready_fds[0]) == 0:
|
||||||
|
# whatever, maybe the socket is closed and select
|
||||||
|
# has nothing to select from?
|
||||||
|
continue
|
||||||
|
|
||||||
|
for fd in ready_fds[0]:
|
||||||
|
if rlist[fd] == self._commands:
|
||||||
|
# all commands will cause the reader to start over
|
||||||
|
# (possibly for a different device).
|
||||||
|
# _handle_commands will check what is going on
|
||||||
|
return
|
||||||
|
|
||||||
|
device = rlist[fd]
|
||||||
|
|
||||||
|
try:
|
||||||
|
event = device.read_one()
|
||||||
|
self._send_event(event, device, gamepad[device.fd])
|
||||||
|
except OSError:
|
||||||
|
logger.debug('Device "%s" disappeared', device.path)
|
||||||
|
return
|
||||||
|
|
||||||
|
def _send_event(self, event, device, gamepad):
|
||||||
|
"""Write the event into the pipe to the main process.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
event : evdev.InputEvent
|
||||||
|
device : evdev.InputDevice
|
||||||
|
gamepad : bool
|
||||||
|
If true, ABS_X and ABS_Y might be mapped to buttons as well
|
||||||
|
depending on the purpose configuration
|
||||||
|
"""
|
||||||
|
# value: 1 for down, 0 for up, 2 for hold.
|
||||||
|
if event.type == EV_KEY and event.value == 2:
|
||||||
|
# ignore hold-down events
|
||||||
|
return
|
||||||
|
|
||||||
|
click_events = [
|
||||||
|
evdev.ecodes.BTN_LEFT,
|
||||||
|
evdev.ecodes.BTN_TOOL_DOUBLETAP
|
||||||
|
]
|
||||||
|
|
||||||
|
if event.type == EV_KEY and event.code in click_events:
|
||||||
|
# disable mapping the left mouse button because it would break
|
||||||
|
# the mouse. Also it is emitted right when focusing the row
|
||||||
|
# which breaks the current workflow.
|
||||||
|
return
|
||||||
|
|
||||||
|
if not utils.should_map_as_btn(event, custom_mapping, gamepad):
|
||||||
|
return
|
||||||
|
|
||||||
|
max_abs = utils.get_max_abs(device)
|
||||||
|
event.value = utils.normalize_value(event, max_abs)
|
||||||
|
|
||||||
|
self._results.send({
|
||||||
|
'type': 'event',
|
||||||
|
'message': (
|
||||||
|
event.sec, event.usec,
|
||||||
|
event.type, event.code, event.value
|
||||||
|
)
|
||||||
|
})
|
@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Named bidirectional non-blocking pipes.
|
||||||
|
|
||||||
|
>>> p1 = Pipe('foo')
|
||||||
|
>>> p2 = Pipe('foo')
|
||||||
|
|
||||||
|
>>> p1.send(1)
|
||||||
|
>>> p2.poll()
|
||||||
|
>>> p2.recv()
|
||||||
|
|
||||||
|
>>> p2.send(2)
|
||||||
|
>>> p1.poll()
|
||||||
|
>>> p1.recv()
|
||||||
|
|
||||||
|
Beware that pipes read any available messages,
|
||||||
|
even those written by themselves.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
from keymapper.logger import logger
|
||||||
|
from keymapper.paths import mkdir, chown
|
||||||
|
|
||||||
|
|
||||||
|
class Pipe:
|
||||||
|
"""Pipe object."""
|
||||||
|
def __init__(self, path):
|
||||||
|
"""Create a pipe, or open it if it already exists."""
|
||||||
|
self._path = path
|
||||||
|
self._unread = []
|
||||||
|
self._created_at = time.time()
|
||||||
|
|
||||||
|
paths = (
|
||||||
|
f'{path}r',
|
||||||
|
f'{path}w'
|
||||||
|
)
|
||||||
|
|
||||||
|
mkdir(os.path.dirname(path))
|
||||||
|
|
||||||
|
if not os.path.exists(paths[0]):
|
||||||
|
logger.spam('Creating new pipe for "%s"', path)
|
||||||
|
# The fd the link points to is closed, or none ever existed
|
||||||
|
# If there is a link, remove it.
|
||||||
|
if os.path.islink(paths[0]):
|
||||||
|
os.remove(paths[0])
|
||||||
|
if os.path.islink(paths[1]):
|
||||||
|
os.remove(paths[1])
|
||||||
|
|
||||||
|
self._fds = os.pipe()
|
||||||
|
fds_dir = f'/proc/{os.getpid()}/fd/'
|
||||||
|
chown(f'{fds_dir}{self._fds[0]}')
|
||||||
|
chown(f'{fds_dir}{self._fds[1]}')
|
||||||
|
|
||||||
|
# to make it accessible by path constants, create symlinks
|
||||||
|
os.symlink(f'{fds_dir}{self._fds[0]}', paths[0])
|
||||||
|
os.symlink(f'{fds_dir}{self._fds[1]}', paths[1])
|
||||||
|
else:
|
||||||
|
logger.spam('Using existing pipe for "%s"', path)
|
||||||
|
|
||||||
|
# thanks to os.O_NONBLOCK, readline will return b'' when there
|
||||||
|
# is nothing to read
|
||||||
|
self._fds = (
|
||||||
|
os.open(paths[0], os.O_RDONLY | os.O_NONBLOCK),
|
||||||
|
os.open(paths[1], os.O_WRONLY | os.O_NONBLOCK)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._handles = (
|
||||||
|
open(self._fds[0], 'r'),
|
||||||
|
open(self._fds[1], 'w')
|
||||||
|
)
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
"""Read an object from the pipe or None if nothing available.
|
||||||
|
|
||||||
|
Doesn't transmit pickles, to avoid injection attacks on the
|
||||||
|
privileged helper. Only messages that can be converted to json
|
||||||
|
are allowed.
|
||||||
|
"""
|
||||||
|
if len(self._unread) > 0:
|
||||||
|
return self._unread.pop(0)
|
||||||
|
|
||||||
|
line = self._handles[0].readline()
|
||||||
|
if len(line) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parsed = json.loads(line)
|
||||||
|
if parsed[0] < self._created_at and os.environ.get('UNITTEST'):
|
||||||
|
# important to avoid race conditions between multiple unittests,
|
||||||
|
# for example old terminate messages reaching a new instance of
|
||||||
|
# the helper.
|
||||||
|
logger.spam('Ignoring old message %s', parsed)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return parsed[1]
|
||||||
|
|
||||||
|
def send(self, message):
|
||||||
|
"""Write an object to the pipe."""
|
||||||
|
dump = json.dumps((time.time(), message))
|
||||||
|
# there aren't any newlines supposed to be,
|
||||||
|
# but if there are it breaks readline().
|
||||||
|
self._handles[1].write(dump.replace('\n', ''))
|
||||||
|
self._handles[1].write('\n')
|
||||||
|
self._handles[1].flush()
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
"""Check if there is anything that can be read."""
|
||||||
|
if len(self._unread) > 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# using select.select apparently won't mark the pipe as ready
|
||||||
|
# anymore when there are multiple lines to read but only a single
|
||||||
|
# line is retreived. Using read instead.
|
||||||
|
msg = self.recv()
|
||||||
|
if msg is not None:
|
||||||
|
self._unread.append(msg)
|
||||||
|
|
||||||
|
return len(self._unread) > 0
|
||||||
|
|
||||||
|
def fileno(self):
|
||||||
|
"""Compatibility to select.select"""
|
||||||
|
return self._handles[0].fileno()
|
@ -0,0 +1,7 @@
|
|||||||
|
# IPC
|
||||||
|
|
||||||
|
Since I'm not forking, I can't use the handy multiprocessing.Pipe
|
||||||
|
method anymore.
|
||||||
|
|
||||||
|
Processes that need privileges are spawned with pkexec, which connect to
|
||||||
|
known pipe paths to communicate with the non-privileged parent process.
|
@ -0,0 +1,297 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Non-blocking abstraction of unix domain sockets.
|
||||||
|
|
||||||
|
>>> server = Server('foo')
|
||||||
|
>>> client = Client('foo')
|
||||||
|
|
||||||
|
>>> server.send(1)
|
||||||
|
>>> client.poll()
|
||||||
|
>>> client.recv()
|
||||||
|
|
||||||
|
>>> client.send(2)
|
||||||
|
>>> server.poll()
|
||||||
|
>>> server.recv()
|
||||||
|
|
||||||
|
I seems harder to sniff on a socket than using pipes for other non-root
|
||||||
|
processes, but it doesn't guarantee security. As long as the GUI is open
|
||||||
|
and not running as root user, it is most likely possible to somehow log
|
||||||
|
keycodes by looking into the memory of the gui process (just like with most
|
||||||
|
other applications because they end up receiving keyboard input as well).
|
||||||
|
It still appears to be a bit overkill to use a socket considering pipes
|
||||||
|
are much easier to handle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# Issues:
|
||||||
|
# - Tests don't pass with Server (reader) and Client (helper) instead of Pipe
|
||||||
|
# - Had one case of a test that was blocking forever, seems very rare.
|
||||||
|
# - Hard to debug, generally very problematic compared to Pipes
|
||||||
|
# The tool works fine, it's just the tests. BrokenPipe errors reported
|
||||||
|
# by _Server all the time.
|
||||||
|
|
||||||
|
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
from keymapper.logger import logger
|
||||||
|
from keymapper.paths import mkdir, chown
|
||||||
|
|
||||||
|
|
||||||
|
# something funny that most likely won't appear in messages.
|
||||||
|
# also add some ones so that 01 in the payload won't offset
|
||||||
|
# a match by 2 bits
|
||||||
|
END = b'\x55\x55\xff\x55' # should be 01010101 01010101 11111111 01010101
|
||||||
|
|
||||||
|
ENCODING = 'utf8'
|
||||||
|
|
||||||
|
|
||||||
|
# reusing existing objects makes tests easier, no headaches about closing
|
||||||
|
# and reopening anymore. The ui also only runs only one instance of each all
|
||||||
|
# the time.
|
||||||
|
existing_servers = {}
|
||||||
|
existing_clients = {}
|
||||||
|
|
||||||
|
|
||||||
|
class Base:
|
||||||
|
"""Abstract base class for Socket and Client."""
|
||||||
|
def __init__(self, path):
|
||||||
|
self._path = path
|
||||||
|
self._unread = []
|
||||||
|
self.unsent = []
|
||||||
|
mkdir(os.path.dirname(path))
|
||||||
|
self.connection = None
|
||||||
|
self.socket = None
|
||||||
|
self._created_at = 0
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""Ignore older messages than now."""
|
||||||
|
# ensure it is connected
|
||||||
|
self.connect()
|
||||||
|
self._created_at = time.time()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Returns True if connected, and if not attempts to connect."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def fileno(self):
|
||||||
|
"""For compatibility with select.select."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def reconnect(self):
|
||||||
|
"""Try to make a new connection."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def _receive_new_messages(self):
|
||||||
|
if not self.connect():
|
||||||
|
logger.spam('Not connected')
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = b''
|
||||||
|
attempts = 0
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
chunk = self.connection.recvmsg(4096)[0]
|
||||||
|
messages += chunk
|
||||||
|
|
||||||
|
if len(chunk) == 0:
|
||||||
|
# select keeps telling me the socket has messages
|
||||||
|
# ready to be received, and I keep getting empty
|
||||||
|
# buffers. Happened during a test that ran two helper
|
||||||
|
# processes without stopping the first one.
|
||||||
|
attempts += 1
|
||||||
|
if attempts == 2 or not self.reconnect():
|
||||||
|
return
|
||||||
|
|
||||||
|
except (socket.timeout, BlockingIOError):
|
||||||
|
break
|
||||||
|
|
||||||
|
split = messages.split(END)
|
||||||
|
for message in split:
|
||||||
|
if len(message) > 0:
|
||||||
|
parsed = json.loads(message.decode(ENCODING))
|
||||||
|
if parsed[0] < self._created_at:
|
||||||
|
# important to avoid race conditions between multiple
|
||||||
|
# unittests, for example old terminate messages reaching
|
||||||
|
# a new instance of the helper.
|
||||||
|
logger.spam('Ignoring old message %s', parsed)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self._unread.append(parsed[1])
|
||||||
|
|
||||||
|
def recv(self):
|
||||||
|
"""Get the next message or None if nothing to read.
|
||||||
|
|
||||||
|
Doesn't transmit pickles, to avoid injection attacks on the
|
||||||
|
privileged helper. Only messages that can be converted to json
|
||||||
|
are allowed.
|
||||||
|
"""
|
||||||
|
self._receive_new_messages()
|
||||||
|
|
||||||
|
if len(self._unread) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._unread.pop(0)
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
"""Check if a message to read is available."""
|
||||||
|
if len(self._unread) > 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self._receive_new_messages()
|
||||||
|
return len(self._unread) > 0
|
||||||
|
|
||||||
|
def send(self, message):
|
||||||
|
"""Send jsonable messages, like numbers, strings or objects."""
|
||||||
|
dump = bytes(json.dumps((time.time(), message)), ENCODING)
|
||||||
|
self.unsent.append(dump)
|
||||||
|
|
||||||
|
if not self.connect():
|
||||||
|
logger.spam('Not connected')
|
||||||
|
return
|
||||||
|
|
||||||
|
def send_all():
|
||||||
|
while len(self.unsent) > 0:
|
||||||
|
unsent = self.unsent[0]
|
||||||
|
self.connection.sendall(unsent + END)
|
||||||
|
# sending worked, remove message
|
||||||
|
self.unsent.pop(0)
|
||||||
|
|
||||||
|
# attempt sending twice in case it fails
|
||||||
|
try:
|
||||||
|
send_all()
|
||||||
|
except BrokenPipeError:
|
||||||
|
if not self.reconnect():
|
||||||
|
logger.error(
|
||||||
|
'%s: The other side of "%s" disappeared',
|
||||||
|
type(self).__name__, self._path
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_all()
|
||||||
|
except BrokenPipeError as error:
|
||||||
|
logger.error(
|
||||||
|
'%s: Failed to send via "%s": %s',
|
||||||
|
type(self).__name__, self._path, error
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _Client(Base):
|
||||||
|
"""A socket that can be written to and read from."""
|
||||||
|
def connect(self):
|
||||||
|
if self.socket is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
_socket = socket.socket(socket.AF_UNIX)
|
||||||
|
_socket.connect(self._path)
|
||||||
|
logger.spam('Connected to socket: "%s"', self._path)
|
||||||
|
_socket.setblocking(False)
|
||||||
|
except Exception as error:
|
||||||
|
logger.spam('Failed to connect to "%s": "%s"', self._path, error)
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.socket = _socket
|
||||||
|
self.connection = _socket
|
||||||
|
existing_clients[self._path] = self
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fileno(self):
|
||||||
|
"""For compatibility with select.select"""
|
||||||
|
self.connect()
|
||||||
|
return self.socket.fileno()
|
||||||
|
|
||||||
|
def reconnect(self):
|
||||||
|
self.connection = None
|
||||||
|
self.socket = None
|
||||||
|
return self.connect()
|
||||||
|
|
||||||
|
|
||||||
|
def Client(path):
|
||||||
|
if path in existing_clients:
|
||||||
|
# ensure it is running, might have been closed
|
||||||
|
existing_clients[path].reset()
|
||||||
|
return existing_clients[path]
|
||||||
|
else:
|
||||||
|
return _Client(path)
|
||||||
|
|
||||||
|
|
||||||
|
class _Server(Base):
|
||||||
|
"""A socket that can be written to and read from.
|
||||||
|
|
||||||
|
It accepts one connection at a time, and drops old connections if
|
||||||
|
a new one is in sight.
|
||||||
|
"""
|
||||||
|
def connect(self):
|
||||||
|
if self.socket is None:
|
||||||
|
if os.path.exists(self._path):
|
||||||
|
# leftover from the previous execution
|
||||||
|
os.remove(self._path)
|
||||||
|
|
||||||
|
_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||||
|
_socket.bind(self._path)
|
||||||
|
_socket.listen(1)
|
||||||
|
chown(self._path)
|
||||||
|
logger.spam('Created socket: "%s"', self._path)
|
||||||
|
self.socket = _socket
|
||||||
|
self.socket.setblocking(False)
|
||||||
|
existing_servers[self._path] = self
|
||||||
|
|
||||||
|
incoming = len(select.select([self.socket], [], [], 0)[0]) != 0
|
||||||
|
if not incoming and self.connection is None:
|
||||||
|
# no existing connection, no client attempting to connect
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not incoming and self.connection is not None:
|
||||||
|
# old connection
|
||||||
|
return True
|
||||||
|
|
||||||
|
if incoming:
|
||||||
|
logger.spam('Incoming connection: "%s"', self._path)
|
||||||
|
connection = self.socket.accept()[0]
|
||||||
|
self.connection = connection
|
||||||
|
self.connection.setblocking(False)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def fileno(self):
|
||||||
|
"""For compatibility with select.select."""
|
||||||
|
self.connect()
|
||||||
|
return self.connection.fileno()
|
||||||
|
|
||||||
|
def reconnect(self):
|
||||||
|
self.connection = None
|
||||||
|
return self.connect()
|
||||||
|
|
||||||
|
|
||||||
|
def Server(path):
|
||||||
|
if path in existing_servers:
|
||||||
|
# ensure it is running, might have been closed
|
||||||
|
existing_servers[path].reset()
|
||||||
|
return existing_servers[path]
|
||||||
|
else:
|
||||||
|
return _Server(path)
|
@ -1,111 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# key-mapper - GUI for device specific keyboard mappings
|
|
||||||
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
|
||||||
#
|
|
||||||
# This file is part of key-mapper.
|
|
||||||
#
|
|
||||||
# key-mapper is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# key-mapper is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
"""To check if access to devices in /dev is possible."""
|
|
||||||
|
|
||||||
|
|
||||||
import grp
|
|
||||||
import glob
|
|
||||||
import getpass
|
|
||||||
import subprocess
|
|
||||||
import os
|
|
||||||
|
|
||||||
from keymapper.logger import logger
|
|
||||||
from keymapper.paths import USER
|
|
||||||
from keymapper.daemon import is_service_running
|
|
||||||
|
|
||||||
|
|
||||||
def check_group(group):
|
|
||||||
"""Check if the required group is active and log if not."""
|
|
||||||
try:
|
|
||||||
in_group = USER in grp.getgrnam(group).gr_mem
|
|
||||||
except KeyError:
|
|
||||||
# group doesn't exist. Ignore
|
|
||||||
return None
|
|
||||||
|
|
||||||
# check if files exist with that group in /dev. Even if plugdev
|
|
||||||
# exists, that doesn't mean that it is needed.
|
|
||||||
used_groups = [os.stat(path).st_gid for path in glob.glob('/dev/input/*')]
|
|
||||||
if grp.getgrnam(group).gr_gid not in used_groups:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not in_group:
|
|
||||||
msg = (
|
|
||||||
'Some devices may not be accessible without being in the '
|
|
||||||
f'"{group}" user group.'
|
|
||||||
)
|
|
||||||
logger.warning(msg)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
try:
|
|
||||||
groups = subprocess.check_output('groups').decode().split()
|
|
||||||
group_active = group in groups
|
|
||||||
except FileNotFoundError:
|
|
||||||
# groups command missing. Idk if any distro doesn't have it
|
|
||||||
# but if so, cover the case.
|
|
||||||
return None
|
|
||||||
|
|
||||||
if in_group and not group_active:
|
|
||||||
msg = (
|
|
||||||
f'You are in the "{group}" group, but your session is not yet '
|
|
||||||
'using it. Some devices may not be accessible. Please log out and '
|
|
||||||
'back in or restart'
|
|
||||||
)
|
|
||||||
logger.warning(msg)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def check_injection_rights():
|
|
||||||
"""Check if the user may write into /dev/uinput."""
|
|
||||||
if not os.access('/dev/uinput', os.W_OK):
|
|
||||||
msg = (
|
|
||||||
'Rights to write to /dev/uinput are missing, keycodes cannot '
|
|
||||||
'be injected.'
|
|
||||||
)
|
|
||||||
logger.error(msg)
|
|
||||||
return msg
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_devices():
|
|
||||||
"""Get a list of problems before key-mapper can be used properly."""
|
|
||||||
if getpass.getuser() == 'root':
|
|
||||||
return []
|
|
||||||
|
|
||||||
input_check = check_group('input')
|
|
||||||
plugdev_check = check_group('plugdev')
|
|
||||||
|
|
||||||
# ubuntu. funnily, individual devices in /dev/input/ have write permitted.
|
|
||||||
if not is_service_running():
|
|
||||||
can_write = check_injection_rights()
|
|
||||||
else:
|
|
||||||
can_write = None
|
|
||||||
|
|
||||||
ret = [
|
|
||||||
check for check
|
|
||||||
in [can_write, input_check, plugdev_check]
|
|
||||||
if check is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
return ret
|
|
@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
"""Figure out the user."""
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
import getpass
|
||||||
|
import pwd
|
||||||
|
|
||||||
|
|
||||||
|
def get_user():
|
||||||
|
"""Try to find the user who called sudo/pkexec."""
|
||||||
|
try:
|
||||||
|
return os.getlogin()
|
||||||
|
except OSError:
|
||||||
|
# failed in some ubuntu installations and in systemd services
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = os.environ['USER']
|
||||||
|
except KeyError:
|
||||||
|
# possibly the systemd service. no sudo was used
|
||||||
|
return getpass.getuser()
|
||||||
|
|
||||||
|
if user == 'root':
|
||||||
|
try:
|
||||||
|
return os.environ['SUDO_USER']
|
||||||
|
except KeyError:
|
||||||
|
# no sudo was used
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkexec_uid = int(os.environ['PKEXEC_UID'])
|
||||||
|
return pwd.getpwuid(pkexec_uid).pw_name
|
||||||
|
except KeyError:
|
||||||
|
# no pkexec was used or the uid is unknown
|
||||||
|
pass
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
USER = get_user()
|
||||||
|
|
||||||
|
HOME = '/root' if USER == 'root' else f'/home/{USER}'
|
||||||
|
|
||||||
|
CONFIG_PATH = os.path.join(HOME, '.config/key-mapper')
|
@ -1,23 +1,23 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="20">
|
<svg xmlns="http://www.w3.org/2000/svg" width="73" height="20">
|
||||||
<linearGradient id="b" x2="0" y2="100%">
|
<linearGradient id="b" x2="0" y2="100%">
|
||||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||||
<stop offset="1" stop-opacity=".1"/>
|
<stop offset="1" stop-opacity=".1"/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<mask id="anybadge_1">
|
<mask id="anybadge_1">
|
||||||
<rect width="80" height="20" rx="3" fill="#fff"/>
|
<rect width="73" height="20" rx="3" fill="#fff"/>
|
||||||
</mask>
|
</mask>
|
||||||
<g mask="url(#anybadge_1)">
|
<g mask="url(#anybadge_1)">
|
||||||
<path fill="#555" d="M0 0h44v20H0z"/>
|
<path fill="#555" d="M0 0h44v20H0z"/>
|
||||||
<path fill="#4c1" d="M44 0h36v20H44z"/>
|
<path fill="#4c1" d="M44 0h29v20H44z"/>
|
||||||
<path fill="url(#b)" d="M0 0h80v20H0z"/>
|
<path fill="url(#b)" d="M0 0h73v20H0z"/>
|
||||||
</g>
|
</g>
|
||||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||||
<text x="23.0" y="15" fill="#010101" fill-opacity=".3">pylint</text>
|
<text x="23.0" y="15" fill="#010101" fill-opacity=".3">pylint</text>
|
||||||
<text x="22.0" y="14">pylint</text>
|
<text x="22.0" y="14">pylint</text>
|
||||||
</g>
|
</g>
|
||||||
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="11">
|
||||||
<text x="63.0" y="15" fill="#010101" fill-opacity=".3">9.84</text>
|
<text x="59.5" y="15" fill="#010101" fill-opacity=".3">9.8</text>
|
||||||
<text x="62.0" y="14">9.84</text>
|
<text x="58.5" y="14">9.8</text>
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# key-mapper - GUI for device specific keyboard mappings
|
||||||
|
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
||||||
|
#
|
||||||
|
# This file is part of key-mapper.
|
||||||
|
#
|
||||||
|
# key-mapper is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# key-mapper is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
import select
|
||||||
|
|
||||||
|
from keymapper.ipc.pipe import Pipe
|
||||||
|
from keymapper.ipc.socket import Server, Client, Base
|
||||||
|
|
||||||
|
|
||||||
|
class TestSocket(unittest.TestCase):
|
||||||
|
def test_socket(self):
|
||||||
|
def test(s1, s2):
|
||||||
|
self.assertEqual(s2.recv(), None)
|
||||||
|
|
||||||
|
s1.send(1)
|
||||||
|
self.assertTrue(s2.poll())
|
||||||
|
self.assertEqual(s2.recv(), 1)
|
||||||
|
self.assertFalse(s2.poll())
|
||||||
|
self.assertEqual(s2.recv(), None)
|
||||||
|
|
||||||
|
s1.send(2)
|
||||||
|
self.assertTrue(s2.poll())
|
||||||
|
s1.send(3)
|
||||||
|
self.assertTrue(s2.poll())
|
||||||
|
self.assertEqual(s2.recv(), 2)
|
||||||
|
self.assertTrue(s2.poll())
|
||||||
|
self.assertEqual(s2.recv(), 3)
|
||||||
|
self.assertFalse(s2.poll())
|
||||||
|
self.assertEqual(s2.recv(), None)
|
||||||
|
|
||||||
|
server = Server('/tmp/key-mapper-test/socket1')
|
||||||
|
client = Client('/tmp/key-mapper-test/socket1')
|
||||||
|
test(server, client)
|
||||||
|
|
||||||
|
client = Client('/tmp/key-mapper-test/socket2')
|
||||||
|
server = Server('/tmp/key-mapper-test/socket2')
|
||||||
|
test(client, server)
|
||||||
|
|
||||||
|
def test_not_connected_1(self):
|
||||||
|
# client discards old message, because it might have had a purpose
|
||||||
|
# for a different client and not for the current one
|
||||||
|
server = Server('/tmp/key-mapper-test/socket3')
|
||||||
|
server.send(1)
|
||||||
|
|
||||||
|
client = Client('/tmp/key-mapper-test/socket3')
|
||||||
|
server.send(2)
|
||||||
|
|
||||||
|
self.assertTrue(client.poll())
|
||||||
|
self.assertEqual(client.recv(), 2)
|
||||||
|
self.assertFalse(client.poll())
|
||||||
|
self.assertEqual(client.recv(), None)
|
||||||
|
|
||||||
|
def test_not_connected_2(self):
|
||||||
|
client = Client('/tmp/key-mapper-test/socket4')
|
||||||
|
client.send(1)
|
||||||
|
|
||||||
|
server = Server('/tmp/key-mapper-test/socket4')
|
||||||
|
client.send(2)
|
||||||
|
|
||||||
|
self.assertTrue(server.poll())
|
||||||
|
self.assertEqual(server.recv(), 2)
|
||||||
|
self.assertFalse(server.poll())
|
||||||
|
self.assertEqual(server.recv(), None)
|
||||||
|
|
||||||
|
def test_select(self):
|
||||||
|
"""is compatible to select.select"""
|
||||||
|
server = Server('/tmp/key-mapper-test/socket6')
|
||||||
|
client = Client('/tmp/key-mapper-test/socket6')
|
||||||
|
|
||||||
|
server.send(1)
|
||||||
|
ready = select.select([client], [], [], 0)[0][0]
|
||||||
|
self.assertEqual(ready, client)
|
||||||
|
|
||||||
|
client.send(2)
|
||||||
|
ready = select.select([server], [], [], 0)[0][0]
|
||||||
|
self.assertEqual(ready, server)
|
||||||
|
|
||||||
|
def test_base_abstract(self):
|
||||||
|
self.assertRaises(NotImplementedError, lambda: Base('foo'))
|
||||||
|
self.assertRaises(NotImplementedError, lambda: Base.connect(None))
|
||||||
|
self.assertRaises(NotImplementedError, lambda: Base.reconnect(None))
|
||||||
|
self.assertRaises(NotImplementedError, lambda: Base.fileno(None))
|
||||||
|
|
||||||
|
|
||||||
|
class TestPipe(unittest.TestCase):
|
||||||
|
def test_pipe_single(self):
|
||||||
|
p1 = Pipe('/tmp/key-mapper-test/pipe')
|
||||||
|
self.assertEqual(p1.recv(), None)
|
||||||
|
|
||||||
|
p1.send(1)
|
||||||
|
self.assertTrue(p1.poll())
|
||||||
|
self.assertEqual(p1.recv(), 1)
|
||||||
|
self.assertFalse(p1.poll())
|
||||||
|
self.assertEqual(p1.recv(), None)
|
||||||
|
|
||||||
|
p1.send(2)
|
||||||
|
self.assertTrue(p1.poll())
|
||||||
|
p1.send(3)
|
||||||
|
self.assertTrue(p1.poll())
|
||||||
|
self.assertEqual(p1.recv(), 2)
|
||||||
|
self.assertTrue(p1.poll())
|
||||||
|
self.assertEqual(p1.recv(), 3)
|
||||||
|
self.assertFalse(p1.poll())
|
||||||
|
self.assertEqual(p1.recv(), None)
|
||||||
|
|
||||||
|
def test_pipe_duo(self):
|
||||||
|
p1 = Pipe('/tmp/key-mapper-test/pipe')
|
||||||
|
p2 = Pipe('/tmp/key-mapper-test/pipe')
|
||||||
|
self.assertEqual(p2.recv(), None)
|
||||||
|
|
||||||
|
p1.send(1)
|
||||||
|
self.assertEqual(p2.recv(), 1)
|
||||||
|
self.assertEqual(p2.recv(), None)
|
||||||
|
|
||||||
|
p1.send(2)
|
||||||
|
p1.send(3)
|
||||||
|
self.assertEqual(p2.recv(), 2)
|
||||||
|
self.assertEqual(p2.recv(), 3)
|
||||||
|
self.assertEqual(p2.recv(), None)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
@ -1,166 +0,0 @@
|
|||||||
#!/usr/bin/python3
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# key-mapper - GUI for device specific keyboard mappings
|
|
||||||
# Copyright (C) 2021 sezanzeb <proxima@sezanzeb.de>
|
|
||||||
#
|
|
||||||
# This file is part of key-mapper.
|
|
||||||
#
|
|
||||||
# key-mapper is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# key-mapper is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with key-mapper. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
|
|
||||||
import os
|
|
||||||
import grp
|
|
||||||
import getpass
|
|
||||||
import subprocess
|
|
||||||
import unittest
|
|
||||||
|
|
||||||
from keymapper.permissions import check_injection_rights, check_group, \
|
|
||||||
can_read_devices
|
|
||||||
from keymapper.paths import USER
|
|
||||||
from keymapper.daemon import is_service_running
|
|
||||||
|
|
||||||
|
|
||||||
original_access = os.access
|
|
||||||
original_getgrnam = grp.getgrnam
|
|
||||||
original_check_output = subprocess.check_output
|
|
||||||
original_stat = os.stat
|
|
||||||
oringal_getuser = getpass.getuser
|
|
||||||
|
|
||||||
|
|
||||||
class TestPermissions(unittest.TestCase):
|
|
||||||
def tearDown(self):
|
|
||||||
# reset all fakes
|
|
||||||
os.access = original_access
|
|
||||||
grp.getgrnam = original_getgrnam
|
|
||||||
subprocess.check_output = original_check_output
|
|
||||||
os.stat = original_stat
|
|
||||||
getpass.getuser = oringal_getuser
|
|
||||||
|
|
||||||
def test_check_injection_rights(self):
|
|
||||||
can_access = False
|
|
||||||
os.access = lambda *args: can_access
|
|
||||||
|
|
||||||
self.assertIsNotNone(check_injection_rights())
|
|
||||||
can_access = True
|
|
||||||
self.assertIsNone(check_injection_rights())
|
|
||||||
|
|
||||||
def fake_setup(self):
|
|
||||||
"""Patch some functions to have the following fake environment:
|
|
||||||
|
|
||||||
Groups
|
|
||||||
------
|
|
||||||
input: id: 0, members: $USER, used in /dev, set up
|
|
||||||
plugdev: id: 1, members: $USER, used in /dev, not in `groups`
|
|
||||||
foobar: id: 2, no members, used in /dev
|
|
||||||
a_unused: id: 0, members: $USER, not used in /dev, set up
|
|
||||||
b_unused: id: 1, members: $USER, not used in /dev, not in `groups`
|
|
||||||
c_unused: id: 2, no members, not used in /dev
|
|
||||||
"""
|
|
||||||
gr_mems = {
|
|
||||||
'input': (0, [USER]),
|
|
||||||
'plugdev': (1, [USER]),
|
|
||||||
'foobar': (2, []),
|
|
||||||
'a_unused': (3, [USER]),
|
|
||||||
'b_unused': (4, [USER]),
|
|
||||||
'c_unused': (5, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
stat_counter = 0
|
|
||||||
|
|
||||||
class stat:
|
|
||||||
def __init__(self, path):
|
|
||||||
nonlocal stat_counter
|
|
||||||
stat_counter += 1
|
|
||||||
# make sure stat returns all of those groups at some point.
|
|
||||||
# only works if there are more than three files in /dev, which
|
|
||||||
# should be the case
|
|
||||||
self.st_gid = [0, 1, 2][stat_counter % 3]
|
|
||||||
|
|
||||||
os.stat = stat
|
|
||||||
|
|
||||||
class getgrnam:
|
|
||||||
def __init__(self, group):
|
|
||||||
if group not in gr_mems:
|
|
||||||
raise KeyError()
|
|
||||||
|
|
||||||
self.gr_gid = gr_mems[group][0]
|
|
||||||
self.gr_mem = gr_mems[group][1]
|
|
||||||
|
|
||||||
grp.getgrnam = getgrnam
|
|
||||||
|
|
||||||
def fake_check_output(cmd):
|
|
||||||
# fake the `groups` output to act like the current session only
|
|
||||||
# has input and a_unused active
|
|
||||||
if cmd == 'groups' or cmd[0] == 'groups':
|
|
||||||
return b'foo input a_unused bar'
|
|
||||||
|
|
||||||
return original_check_output(cmd)
|
|
||||||
|
|
||||||
subprocess.check_output = fake_check_output
|
|
||||||
|
|
||||||
def test_can_read_devices(self):
|
|
||||||
self.fake_setup()
|
|
||||||
self.assertFalse(is_service_running())
|
|
||||||
|
|
||||||
# root user doesn't need this stuff
|
|
||||||
getpass.getuser = lambda: 'root'
|
|
||||||
self.assertEqual(len(can_read_devices()), 0)
|
|
||||||
|
|
||||||
getpass.getuser = lambda: USER
|
|
||||||
os.access = lambda *args: False
|
|
||||||
# plugdev not yet setup correctly and cannot write
|
|
||||||
self.assertEqual(len(can_read_devices()), 2)
|
|
||||||
|
|
||||||
os.access = lambda *args: True
|
|
||||||
self.assertEqual(len(can_read_devices()), 1)
|
|
||||||
|
|
||||||
subprocess.check_output = lambda cmd: b'plugdev input'
|
|
||||||
self.assertEqual(len(can_read_devices()), 0)
|
|
||||||
|
|
||||||
def test_check_group(self):
|
|
||||||
self.fake_setup()
|
|
||||||
|
|
||||||
# correctly setup
|
|
||||||
self.assertIsNone(check_group('input'))
|
|
||||||
|
|
||||||
# session restart required, usermod already done
|
|
||||||
self.assertIsNotNone(check_group('plugdev'))
|
|
||||||
self.assertIn('plugdev', check_group('plugdev'))
|
|
||||||
self.assertIn('session', check_group('plugdev'))
|
|
||||||
|
|
||||||
# usermod required
|
|
||||||
self.assertIsNotNone(check_group('foobar'))
|
|
||||||
self.assertIn('foobar', check_group('foobar'))
|
|
||||||
self.assertIn('group', check_group('foobar'))
|
|
||||||
|
|
||||||
# don't exist in /dev
|
|
||||||
self.assertIsNone(check_group('a_unused'))
|
|
||||||
self.assertIsNone(check_group('b_unused'))
|
|
||||||
self.assertIsNone(check_group('c_unused'))
|
|
||||||
|
|
||||||
# group doesn't exist
|
|
||||||
self.assertIsNone(check_group('qux'))
|
|
||||||
|
|
||||||
def file_not_found_error(cmd):
|
|
||||||
raise FileNotFoundError()
|
|
||||||
subprocess.check_output = file_not_found_error
|
|
||||||
|
|
||||||
# groups command doesn't exist, so cannot check this suff
|
|
||||||
self.assertIsNone(check_group('plugdev'))
|
|
||||||
# which doesn't affect the grp lib
|
|
||||||
self.assertIsNotNone(check_group('foobar'))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
Loading…
Reference in New Issue