Add quarantined Xorg destination-key ownership and cleanup
This commit is contained in:
1 parent
792cac5d0b
commit
cd9ae05bbe
10 files changed
+988
-29
No files matched your search
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
/*
|
||||
#include "commands_native.h"
|
||||
#include <X11/XKBlib.h>
|
||||
#include <X11/extensions/XTest.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
static Display *remote_key_open(void) {
|
||||
Display *display = XOpenDisplay(NULL);
|
||||
if (!display) return NULL;
|
||||
int event, error, major, minor;
|
||||
if (!XTestQueryExtension(display, &event, &error, &major, &minor)) {
|
||||
XCloseDisplay(display);
|
||||
return NULL;
|
||||
}
|
||||
return display;
|
||||
}
|
||||
|
||||
static int remote_key_resolve(Display *display, const char *name) {
|
||||
KeySym wanted = XStringToKeysym(name);
|
||||
if (wanted == NoSymbol) return 0;
|
||||
int result = 0;
|
||||
cthulhu_x11_begin_read(display);
|
||||
XkbStateRec state;
|
||||
XkbDescPtr map = XkbGetMap(display, XkbAllClientInfoMask, XkbUseCoreKbd);
|
||||
// Multi-group and level-three policy is not implemented. Never guess a
|
||||
// physical key from an inactive group's symbol or a shifted-only symbol.
|
||||
if (map && XkbGetState(display, XkbUseCoreKbd, &state) == Success && state.group == 0) {
|
||||
unsigned int modifiers = state.mods & ~(ShiftMask | LockMask);
|
||||
for (int code = map->min_key_code; code <= map->max_key_code; code++) {
|
||||
unsigned int consumed;
|
||||
KeySym actual;
|
||||
if (XkbTranslateKeyCode(map, code, modifiers, &consumed, &actual) && actual == wanted) {
|
||||
if (result) { result = 0; break; } // Ambiguous destination.
|
||||
result = code;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (map) XkbFreeKeyboard(map, XkbAllComponentsMask, True);
|
||||
if (cthulhu_x11_end_read(display)) result = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
static int remote_key_held(Display *display, unsigned int code) {
|
||||
char held[32];
|
||||
cthulhu_x11_begin_read(display);
|
||||
int success = XQueryKeymap(display, held);
|
||||
int error = cthulhu_x11_end_read(display);
|
||||
if (!success || error) return -1;
|
||||
return !!((unsigned char)held[code / 8] & (1u << (code % 8)));
|
||||
}
|
||||
|
||||
static int remote_key_set(Display *display, unsigned int code, int pressed) {
|
||||
cthulhu_x11_begin_read(display);
|
||||
int success = XTestFakeKeyEvent(display, code, pressed, CurrentTime);
|
||||
int error = cthulhu_x11_end_read(display);
|
||||
return success && !error;
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
// Quarantined native prerequisite, with no production caller. All methods
|
||||
// require one serial caller on a locked OS thread. Xlib can block: the future
|
||||
// production owner must live in an independent helper, never the reader loop.
|
||||
type remoteKeyTarget struct {
|
||||
backend *remoteKeyBackend
|
||||
ledger *input.RemoteKeyLedger
|
||||
}
|
||||
|
||||
type remoteKeyBackend struct{ display *C.Display }
|
||||
|
||||
func openRemoteKeyTarget() (*remoteKeyTarget, error) {
|
||||
display := C.remote_key_open()
|
||||
if display == nil {
|
||||
return nil, input.ErrRemoteKeyUnavailable
|
||||
}
|
||||
backend := &remoteKeyBackend{display: display}
|
||||
ledger, err := input.NewRemoteKeyLedger(backend)
|
||||
if err != nil {
|
||||
C.XCloseDisplay(display)
|
||||
return nil, err
|
||||
}
|
||||
return &remoteKeyTarget{backend: backend, ledger: ledger}, nil
|
||||
}
|
||||
|
||||
func (backend *remoteKeyBackend) Resolve(name string) (uint16, error) {
|
||||
if backend.display == nil || !input.ValidRemoteKeyName(name) {
|
||||
return 0, input.ErrRemoteKeyUnavailable
|
||||
}
|
||||
value := C.CString(name)
|
||||
defer C.free(unsafe.Pointer(value))
|
||||
code := C.remote_key_resolve(backend.display, value)
|
||||
if code < 8 || code > 255 {
|
||||
return 0, input.ErrRemoteKeyUnavailable
|
||||
}
|
||||
return uint16(code), nil
|
||||
}
|
||||
|
||||
func (backend *remoteKeyBackend) Held(code uint16) (bool, error) {
|
||||
if backend.display == nil || code < 8 || code > 255 {
|
||||
return false, input.ErrRemoteKeyBackend
|
||||
}
|
||||
held := C.remote_key_held(backend.display, C.uint(code))
|
||||
if held < 0 {
|
||||
return false, input.ErrRemoteKeyBackend
|
||||
}
|
||||
return held != 0, nil
|
||||
}
|
||||
|
||||
func (backend *remoteKeyBackend) Set(code uint16, pressed bool) error {
|
||||
if backend.display == nil || code < 8 || code > 255 {
|
||||
return input.ErrRemoteKeyBackend
|
||||
}
|
||||
var down C.int
|
||||
if pressed {
|
||||
down = 1
|
||||
}
|
||||
if C.remote_key_set(backend.display, C.uint(code), down) == 0 {
|
||||
return input.ErrRemoteKeyBackend
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (target *remoteKeyTarget) apply(name string, pressed bool) error {
|
||||
return target.ledger.Apply(name, pressed)
|
||||
}
|
||||
|
||||
func (target *remoteKeyTarget) retire() error { return target.ledger.Retire() }
|
||||
|
||||
// Failed cleanup keeps the connection and ledger available for explicit retry.
|
||||
// Closing a connection alone is not XTEST key cleanup.
|
||||
func (target *remoteKeyTarget) close() error {
|
||||
if err := target.retire(); err != nil {
|
||||
return err
|
||||
}
|
||||
if target.backend.display != nil {
|
||||
C.XCloseDisplay(target.backend.display)
|
||||
target.backend.display = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package xorg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
)
|
||||
|
||||
func remoteNativeThread(t *testing.T) {
|
||||
t.Helper()
|
||||
if os.Getenv("CTHULHU_NATIVE_REMOTE_KEYS") != "1" {
|
||||
t.Skip("requires private native_remote_keys.py session")
|
||||
}
|
||||
runtime.LockOSThread()
|
||||
t.Cleanup(runtime.UnlockOSThread)
|
||||
remoteNativeLayout(t, "us")
|
||||
t.Cleanup(func() { remoteNativeLayout(t, "us") })
|
||||
}
|
||||
|
||||
func remoteNativeLayout(t *testing.T, layout string) {
|
||||
t.Helper()
|
||||
// Go cancels t.Context before cleanup; restoring the private keymap still
|
||||
// needs its own bounded lifetime after that cancellation.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if output, err := exec.CommandContext(ctx, "setxkbmap", "-layout", layout).CombinedOutput(); err != nil {
|
||||
t.Fatalf("private keymap update: %v: %s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteNativeState(t *testing.T, action string) {
|
||||
t.Helper()
|
||||
const script = `import ctypes as C, sys
|
||||
x = C.CDLL('libX11.so.6')
|
||||
signatures = {
|
||||
'XOpenDisplay': ([C.c_char_p], C.c_void_p),
|
||||
'XCloseDisplay': ([C.c_void_p], C.c_int),
|
||||
'XSync': ([C.c_void_p, C.c_int], C.c_int),
|
||||
'XStringToKeysym': ([C.c_char_p], C.c_ulong),
|
||||
'XkbKeysymToModifiers': ([C.c_void_p, C.c_ulong], C.c_uint),
|
||||
'XkbLockModifiers': ([C.c_void_p, C.c_uint, C.c_uint, C.c_uint], C.c_int),
|
||||
'XkbLockGroup': ([C.c_void_p, C.c_uint, C.c_uint], C.c_int),
|
||||
}
|
||||
for name, (arguments, result) in signatures.items():
|
||||
function = getattr(x, name)
|
||||
function.argtypes, function.restype = arguments, result
|
||||
display = x.XOpenDisplay(None)
|
||||
assert display
|
||||
try:
|
||||
action = sys.argv[1]
|
||||
if action.startswith('group-'):
|
||||
assert x.XkbLockGroup(display, 0x100, int(action[-1]))
|
||||
else:
|
||||
mask = x.XkbKeysymToModifiers(display, x.XStringToKeysym(b'Num_Lock'))
|
||||
assert mask
|
||||
assert x.XkbLockModifiers(display, 0x100, mask, mask if action == 'num-on' else 0)
|
||||
x.XSync(display, False)
|
||||
finally:
|
||||
x.XCloseDisplay(display)
|
||||
`
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if output, err := exec.CommandContext(ctx, "python3", "-c", script, action).CombinedOutput(); err != nil {
|
||||
t.Fatalf("private XKB state update: %v: %s", err, output)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteNativeTarget(t *testing.T) *remoteKeyTarget {
|
||||
t.Helper()
|
||||
target, err := openRemoteKeyTarget()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := target.close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return target
|
||||
}
|
||||
|
||||
func remoteNativeHeld(t *testing.T, probe *remoteKeyTarget, code uint16, want bool) {
|
||||
t.Helper()
|
||||
if held, err := probe.backend.Held(code); err != nil || held != want {
|
||||
t.Fatalf("native held state = %v, %v; want %v", held, err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetOwnedCleanup(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
target, probe := remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
codes := map[string]uint16{}
|
||||
for _, name := range []string{"Shift_L", "Control_R", "a"} {
|
||||
code, err := target.backend.Resolve(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
codes[name] = code
|
||||
if err := target.apply(name, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, code, true)
|
||||
if err := target.apply(name, true); err != nil {
|
||||
t.Fatal("duplicate down lost ownership", err)
|
||||
}
|
||||
}
|
||||
if err := target.apply("a", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, codes["a"], false)
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, code := range codes {
|
||||
remoteNativeHeld(t, probe, code, false)
|
||||
}
|
||||
if err := target.apply("a", true); !errors.Is(err, input.ErrRemoteKeyState) {
|
||||
t.Fatal("retired native target resumed")
|
||||
}
|
||||
if err := target.close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.backend.Resolve("a"); !errors.Is(err, input.ErrRemoteKeyUnavailable) {
|
||||
t.Fatal("closed backend admitted a new lookup")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetPreservesUnownedKey(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
other, target, probe := remoteNativeTarget(t), remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
shift, err := other.backend.Resolve("Shift_L")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
control, err := target.backend.Resolve("Control_R")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := other.apply("Shift_L", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.apply("Shift_L", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, shift, true)
|
||||
if err := target.apply("Control_R", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.apply("Shift_L", true); !errors.Is(err, input.ErrRemoteKeyCollision) {
|
||||
t.Fatal("already-held target was adopted", err)
|
||||
}
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, control, false)
|
||||
remoteNativeHeld(t, probe, shift, true)
|
||||
if err := other.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, shift, false)
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetLayoutChangeKeepsRelease(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
target, probe := remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
before, err := target.backend.Resolve("y")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.apply("y", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeLayout(t, "de")
|
||||
after, err := target.backend.Resolve("y")
|
||||
if err != nil || before == after {
|
||||
t.Fatal("fresh XKB snapshot did not observe changed destination layout", err)
|
||||
}
|
||||
if err := target.apply("y", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, before, false)
|
||||
remoteNativeHeld(t, probe, after, false)
|
||||
if err := target.apply("y", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, after, true)
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, after, false)
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetRejectsUnavailableAndLocks(t *testing.T) {
|
||||
for _, name := range []string{"unknown private name", "Caps_Lock", "Num_Lock", "Scroll_Lock", "semicolon"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
target, probe := remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
shift, err := target.backend.Resolve("Shift_L")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.apply("Shift_L", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if name == "semicolon" {
|
||||
remoteNativeLayout(t, "de") // Semicolon needs Shift on this layout.
|
||||
}
|
||||
if err := target.apply(name, true); !errors.Is(err, input.ErrRemoteKeyUnavailable) {
|
||||
t.Fatal("unsupported key was admitted", err)
|
||||
}
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, shift, false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetNumLockKeepsRelease(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
target, probe := remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
remoteNativeState(t, "num-off")
|
||||
t.Cleanup(func() { remoteNativeState(t, "num-off") })
|
||||
code, err := target.backend.Resolve("KP_Insert")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := target.backend.Resolve("KP_0"); !errors.Is(err, input.ErrRemoteKeyUnavailable) {
|
||||
t.Fatal("resolver guessed a keypad number with Num Lock off")
|
||||
}
|
||||
if err := target.apply("KP_Insert", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeState(t, "num-on")
|
||||
current, err := target.backend.Resolve("KP_0")
|
||||
if err != nil || current != code {
|
||||
t.Fatal("fresh resolver did not preserve keypad identity across Num Lock", err)
|
||||
}
|
||||
if _, err := target.backend.Resolve("KP_Insert"); !errors.Is(err, input.ErrRemoteKeyUnavailable) {
|
||||
t.Fatal("resolver guessed keypad navigation with Num Lock on")
|
||||
}
|
||||
if err := target.apply("KP_Insert", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, code, false)
|
||||
if err := target.apply("KP_0", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, code, true)
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, code, false)
|
||||
}
|
||||
|
||||
func TestNativeRemoteKeyTargetRejectsInactiveGroup(t *testing.T) {
|
||||
remoteNativeThread(t)
|
||||
target, probe := remoteNativeTarget(t), remoteNativeTarget(t)
|
||||
shift, err := target.backend.Resolve("Shift_L")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := target.apply("Shift_L", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeLayout(t, "us,de")
|
||||
remoteNativeState(t, "group-1")
|
||||
t.Cleanup(func() { remoteNativeState(t, "group-0") })
|
||||
if err := target.apply("a", true); !errors.Is(err, input.ErrRemoteKeyUnavailable) {
|
||||
t.Fatal("unimplemented group policy admitted a key", err)
|
||||
}
|
||||
if err := target.retire(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteNativeHeld(t, probe, shift, false)
|
||||
}
|
||||
Reference in new issue
Block a user