From cd9ae05bbe1a1ad6a106d443eabdb6a3d24e720b Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Thu, 5 Nov 2026 21:56:14 -0500 Subject: [PATCH] Add quarantined Xorg destination-key ownership and cleanup --- docs/tentacle-remote-keyboard.md | 10 +- docs/testing/remote-input-feasibility.md | 4 + docs/testing/remote-key-target.md | 110 +++++++ internal/input/remote_key_ledger.go | 145 +++++++++ internal/input/remote_key_ledger_test.go | 217 +++++++++++++ internal/input/remote_key_names.go | 35 +++ internal/platform/xorg/remote_keys.go | 152 ++++++++++ .../platform/xorg/remote_keys_native_test.go | 286 ++++++++++++++++++ internal/tentaclewire/remote_key.go | 30 +- tests/native_remote_keys.py | 28 ++ 10 files changed, 988 insertions(+), 29 deletions(-) create mode 100644 docs/testing/remote-key-target.md create mode 100644 internal/input/remote_key_ledger.go create mode 100644 internal/input/remote_key_ledger_test.go create mode 100644 internal/input/remote_key_names.go create mode 100644 internal/platform/xorg/remote_keys.go create mode 100644 internal/platform/xorg/remote_keys_native_test.go create mode 100644 tests/native_remote_keys.py diff --git a/docs/tentacle-remote-keyboard.md b/docs/tentacle-remote-keyboard.md index fcece77..25bcfc7 100644 --- a/docs/tentacle-remote-keyboard.md +++ b/docs/tentacle-remote-keyboard.md @@ -35,8 +35,9 @@ allowlist matches the external mapper's canonical subset: Aliases such as `Prior`, uppercase text, shifted character names, AltGr, dead/IME keys, arbitrary Unicode and unrecognized names remain unsupported. -For exact case/spelling, see `ValidRemoteKey` in -`internal/tentaclewire/remote_key.go`. No implicit modifier transitions, text +For exact case/spelling, see `input.ValidRemoteKeyName` in +`internal/input/remote_key_names.go`, used by `ValidRemoteKey` in the wire/SDK +layer. No implicit modifier transitions, text insertion or fallback commands are synthesized. Go exposes `RemoteKey`, `RemoteKeyRequest`, `ValidRemoteKey` and @@ -105,6 +106,11 @@ concurrent idempotent release, overflow and retirement/replacement. The external bridge verifies that every canonical mapper result satisfies the public SDK contract. None of these tests injects a desktop key. +An unconnected [destination-key prerequisite](testing/remote-key-target.md) now +retains native codes through layout changes and uncertain effects, with a +quarantined Xorg resolver/backend. It is not attached to this mailbox and supplies +no independent process/watchdog cleanup or physical collision guarantee. + No capture event API, production Xorg input owner, independent native cleanup helper, core escape/bypass integration or installed Remote keyboard command is provided. Physical-source approval, physical collision/recovery, audible speech, diff --git a/docs/testing/remote-input-feasibility.md b/docs/testing/remote-input-feasibility.md index 8e9a50d..f870e94 100644 --- a/docs/testing/remote-input-feasibility.md +++ b/docs/testing/remote-input-feasibility.md @@ -2,6 +2,10 @@ This is an isolated prototype and a record of limitations, not a Remote input implementation. No reader input behavior or installed executable changes. +A subsequent [Go destination-key prerequisite](remote-key-target.md) now supplies +stored-code ownership, retryable cleanup and a quarantined native resolver. It +does not inherit this Python prototype's independent EOF/watchdog behavior and +is not attached to production input. The source baseline is `a47253d697c1aa44dea80646cad99a4fe163c7f1` on `testing`, with the existing secret-entry work preserved. The branch was checked against its upstream on 2026-11-03 before these tests were added. diff --git a/docs/testing/remote-key-target.md b/docs/testing/remote-key-target.md new file mode 100644 index 0000000..ccd316d --- /dev/null +++ b/docs/testing/remote-key-target.md @@ -0,0 +1,110 @@ +# Quarantined Remote destination-key prerequisite + +The reader has a destination-key ledger and an unconnected Xorg backend. +`internal/input/remote_key_ledger.go` owns at most 32 native destination codes; +`internal/platform/xorg/remote_keys.go` resolves and applies the supported +logical keys on a dedicated X connection. No production code constructs this +target, and no reader input owner is attached to `remote.key`. The Remote +command still discards relay keyboard traffic and offers no keyboard grant. + +This is a native prerequisite, not a usable input service. It must remain +quarantined until independent helper cleanup, input provenance and physical +collision handling are established. + +## Ownership and cleanup + +One ledger belongs to one serial native owner and helper lifetime. Its caller +must validate the original lease immediately before each effect, retire on +cancellation, and finish cleanup before allowing replacement input. The ledger +itself neither issues consent nor checks lease epochs. It must not be shared +between leases or mutated concurrently. + +A new logical down resolves one destination code and rejects invalid codes, +another owned name resolving to that code, or an already-held server key. It +reserves the code before attempting the down, because a native failure can leave +the outcome uncertain. Duplicate downs are idempotent and perform no additional +injection; native repeat fidelity remains unfinished. An unowned up performs +no native operation. An owned up uses its reserved code without resolving the +name again, including after layout/Num Lock changes. + +Mapping failure, collision, capacity overflow or native failure stops admission. +`Retire` attempts every owned up in reverse down order. Successful ups are +removed; failed ups retain their exact code and original relative order for +explicit cleanup retry. Retirement never resumes admission. Backend errors +become constant local errors, without native diagnostic text or key names. + +The native wrapper closes its connection only after successful ledger cleanup. +Failed cleanup leaves the connection and pending identities available for retry. +Opening replacement input must wait for confirmed old cleanup; a new consent +epoch does not make an old native release harmless. Closing an XTEST connection +or killing this process does not release held keys. The earlier +[Python feasibility harness](remote-input-feasibility.md) demonstrates that +limitation; its separate EOF/watchdog mechanism is not implemented by this Go +target. + +## Native resolution and limits + +The logical-key vocabulary now belongs to `input.ValidRemoteKeyName`; the wire +and Go SDK validators delegate to it, preserving the existing IPC schema and +Python conformance. Native input therefore uses the same bounded names as the +public SDK, without depending on the Tentacle wire package. + +The backend fetches a fresh XKB map/state for each new resolution and requires +one unambiguous matching destination code. It supports group zero only; +nonzero groups are unavailable. Shift/Caps state is removed for logical base-key +resolution while Num Lock is retained. Shift-only punctuation, unavailable or +ambiguous symbols are rejected. It does not synthesize modifiers, assume a +Windows scan code equals an X code, or rewrite the global keymap. The private +fixture changes maps and lock state explicitly to exercise these cases. +See the [XKB library specification](https://xorg.freedesktop.org/archive/current/doc/libX11/XKB/xkblib.html) +for `XkbGetMap`, `XkbGetState` and `XkbTranslateKeyCode`. + +Lock-key downs are unavailable until lock synchronization and reader modifier +handling have a defined policy. This includes Caps Lock even when a reader +configuration would otherwise use it as a modifier. AltGr, IME/dead keys, +general group/level handling and exact remote autorepeat remain unfinished. + +The already-held-key check is not atomic with injection. A later physical or +other-client press of an owned code is not separately represented by the global +bitmap. This backend cannot preserve that press during cleanup. Fresh map reads +also do not freeze configuration changes by other clients. No physical-device, +concurrent-map-change or same-code collision guarantee is claimed. + +All native wrapper methods require one serial caller on a locked OS thread. +Xlib calls and the error guard can block or terminate a process after X-server +failure. The future production target must run in an independent helper, with +bounded inherited-descriptor IPC, watchdog/EOF cleanup, exclusive ownership, +provenance, policy cancellation and explicit failure reporting. Do not attach +this synchronous wrapper to the reader event loop or directly to its mailbox. + +## Private verification + +From the reader checkout: + +```sh +go test -race ./internal/input ./internal/tentaclewire -count=1 +python3 -B tests/native_remote_keys.py +python3 -B tests/native_remote_input.py +``` + +The new harness requires Go, Python, Xvfb, private D-Bus and `setxkbmap`. It starts +a 24-bit Xvfb server before private D-Bus using `native_test_session.py`, with a +disposable runtime directory. It never uses the active reader, display or +accessibility bus. Missing dependencies fail the harness rather than count as +skipped acceptance. The native tests skip in ordinary unflagged Go suites; +only an actual harness run establishes their private results. + +Unit tests cover stored-code releases, idempotent downs, unowned ups, alias and +preexisting collisions, constant errors, held-key limits, uncertain down/up +outcomes, partial cleanup and retry order. Six new native Go race tests verify +owned modifier cleanup, preservation of an already-held key from another +synthetic client, US/German map replacement, Num Lock changes, unsupported-group +and shift-only-symbol rejection, and lock-key refusal. They inspect server key +state, not application command delivery or hardware behavior. + +The new native harness, existing ten-experiment/six-native-test feasibility +harness, input/wire/Xorg race suites, focused host keyboard/lifecycle race tests, +external Remote race suite, affected-package vet, a separate reader build, +the existing speech regression harness and Tentacles `make check` passed. +No full reader suite, installed-binary behavior, +audible speech, physical input/Braille or live NVDA/Orca acceptance was performed. diff --git a/internal/input/remote_key_ledger.go b/internal/input/remote_key_ledger.go new file mode 100644 index 0000000..dd8c1a1 --- /dev/null +++ b/internal/input/remote_key_ledger.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "errors" + +const RemoteHeldKeyLimit = 32 + +var ( + ErrRemoteKeyState = errors.New("remote input owner is retired") + ErrRemoteKeyUnavailable = errors.New("remote input key is unavailable") + ErrRemoteKeyCollision = errors.New("remote input key ownership is ambiguous") + ErrRemoteKeyBackend = errors.New("remote input operation failed") + ErrRemoteKeyLimit = errors.New("remote input held-key limit exceeded") +) + +// RemoteKeyBackend belongs to one serial native owner. Set can fail after an +// effect reached the server; the ledger therefore reserves before down and +// retains ownership until up succeeds. This interface supplies no consent, +// watchdog, physical-source tracking or independent crash recovery. Resolve +// returns an Xorg destination code in 8..255, never a VK or scan code. +type RemoteKeyBackend interface { + Resolve(name string) (uint16, error) + Held(code uint16) (bool, error) + Set(code uint16, pressed bool) error +} + +// RemoteKeyLedger retains destination codes, not source keycodes or relay VKs. +// One instance belongs to one native helper lifetime; its caller must serialize +// all methods, validate the original lease at delivery and retire on revoke. +type RemoteKeyLedger struct { + backend RemoteKeyBackend + held map[string]uint16 + order []string + stopped bool +} + +func NewRemoteKeyLedger(backend RemoteKeyBackend) (*RemoteKeyLedger, error) { + if backend == nil { + return nil, ErrRemoteKeyUnavailable + } + return &RemoteKeyLedger{backend: backend, held: make(map[string]uint16)}, nil +} + +func (ledger *RemoteKeyLedger) fail(err error) error { + ledger.stopped = true + return err +} + +func (ledger *RemoteKeyLedger) Apply(name string, pressed bool) error { + if ledger == nil || ledger.stopped { + return ErrRemoteKeyState + } + if !ValidRemoteKeyName(name) { + return ledger.fail(ErrRemoteKeyUnavailable) + } + code, owned := ledger.held[name] + if owned { + if pressed { + // Idempotent duplicate downs. Native repeat synthesis/lock policy + // remain unavailable; do not generate extra transitions here. + return nil + } + if err := ledger.backend.Set(code, false); err != nil { + return ledger.fail(ErrRemoteKeyBackend) + } + delete(ledger.held, name) + for index, held := range ledger.order { + if held == name { + copy(ledger.order[index:], ledger.order[index+1:]) + ledger.order[len(ledger.order)-1] = "" + ledger.order = ledger.order[:len(ledger.order)-1] + break + } + } + return nil + } + if !pressed { + return nil // An unowned up must not release a physical/local key. + } + if name == "Caps_Lock" || name == "Num_Lock" || name == "Scroll_Lock" { + // Lock synchronization and reader-modifier handling need their own + // native policy. The quarantined backend must not toggle local locks. + return ledger.fail(ErrRemoteKeyUnavailable) + } + if len(ledger.held) == RemoteHeldKeyLimit { + return ledger.fail(ErrRemoteKeyLimit) + } + code, err := ledger.backend.Resolve(name) + if err != nil || code < 8 || code > 255 { + return ledger.fail(ErrRemoteKeyUnavailable) + } + for _, other := range ledger.held { + if other == code { + return ledger.fail(ErrRemoteKeyCollision) + } + } + held, err := ledger.backend.Held(code) + if err != nil { + return ledger.fail(ErrRemoteKeyBackend) + } + if held { + return ledger.fail(ErrRemoteKeyCollision) + } + ledger.held[name] = code + ledger.order = append(ledger.order, name) + if err := ledger.backend.Set(code, true); err != nil { + return ledger.fail(ErrRemoteKeyBackend) + } + return nil +} + +// Retire stops admission permanently and releases only reserved target codes, +// in reverse down order. Failed ups retain their identity for explicit cleanup +// retry; a successful repeat call has no effects. Cleanup must complete before +// destroying the native backend. Never resume a retired ledger. +func (ledger *RemoteKeyLedger) Retire() error { + if ledger == nil { + return ErrRemoteKeyState + } + ledger.stopped = true + failed := false + for index := len(ledger.order) - 1; index >= 0; index-- { + name := ledger.order[index] + if err := ledger.backend.Set(ledger.held[name], false); err != nil { + failed = true + } else { + delete(ledger.held, name) + } + } + remaining := ledger.order[:0] + for _, name := range ledger.order { + if _, owned := ledger.held[name]; owned { + remaining = append(remaining, name) + } + } + clear(ledger.order[len(remaining):]) + ledger.order = remaining + if failed { + return ErrRemoteKeyBackend + } + return nil +} diff --git a/internal/input/remote_key_ledger_test.go b/internal/input/remote_key_ledger_test.go new file mode 100644 index 0000000..a678a8b --- /dev/null +++ b/internal/input/remote_key_ledger_test.go @@ -0,0 +1,217 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +type ledgerTransition struct { + code uint16 + down bool +} + +type ledgerBackend struct { + codes map[string]uint16 + held map[uint16]bool + effects []ledgerTransition + resolves []string + resolveError error + heldError error + setError func(uint16, bool) error +} + +func (backend *ledgerBackend) Resolve(name string) (uint16, error) { + backend.resolves = append(backend.resolves, name) + return backend.codes[name], backend.resolveError +} +func (backend *ledgerBackend) Held(code uint16) (bool, error) { + return backend.held[code], backend.heldError +} +func (backend *ledgerBackend) Set(code uint16, down bool) error { + backend.effects = append(backend.effects, ledgerTransition{code, down}) + // Model a backend that applies an effect before reporting uncertainty. + backend.held[code] = down + if backend.setError != nil { + return backend.setError(code, down) + } + return nil +} + +func ledgerFixture(t *testing.T) (*RemoteKeyLedger, *ledgerBackend) { + t.Helper() + backend := &ledgerBackend{codes: map[string]uint16{"a": 38, "q": 24, "Shift_L": 50, "Control_R": 105}, held: make(map[uint16]bool)} + ledger, err := NewRemoteKeyLedger(backend) + if err != nil { + t.Fatal(err) + } + return ledger, backend +} + +func applyLedgerKey(t *testing.T, ledger *RemoteKeyLedger, name string, down bool) { + t.Helper() + if err := ledger.Apply(name, down); err != nil { + t.Fatal(err) + } +} + +func TestRemoteKeyLedgerRetainsDestinationThroughLayoutChange(t *testing.T) { + ledger, backend := ledgerFixture(t) + applyLedgerKey(t, ledger, "a", true) + backend.codes["a"] = 24 + applyLedgerKey(t, ledger, "a", true) // Duplicate down is idempotent. + applyLedgerKey(t, ledger, "a", false) + applyLedgerKey(t, ledger, "a", false) // Unowned up has no native effect. + applyLedgerKey(t, ledger, "a", true) + if err := ledger.Retire(); err != nil { + t.Fatal(err) + } + want := []ledgerTransition{{38, true}, {38, false}, {24, true}, {24, false}} + if !reflect.DeepEqual(backend.effects, want) || !reflect.DeepEqual(backend.resolves, []string{"a", "a"}) { + t.Fatal("layout changes or duplicate transitions changed native ownership", backend.effects) + } + if len(ledger.held) != 0 || len(ledger.order) != 0 { + t.Fatal("successful retirement retained keys") + } + if err := ledger.Retire(); err != nil || len(backend.effects) != len(want) { + t.Fatal("successful retirement was not idempotent") + } + if !errors.Is(ledger.Apply("a", true), ErrRemoteKeyState) { + t.Fatal("retired ledger resumed") + } +} + +func TestRemoteKeyLedgerRejectsCollisionsWithoutAdopting(t *testing.T) { + for _, collision := range []string{"preexisting", "alias"} { + t.Run(collision, func(t *testing.T) { + ledger, backend := ledgerFixture(t) + applyLedgerKey(t, ledger, "Control_R", true) + if collision == "preexisting" { + backend.held[38] = true + } else { + backend.codes["a"] = 105 + } + if !errors.Is(ledger.Apply("a", true), ErrRemoteKeyCollision) { + t.Fatal("ambiguous key ownership accepted") + } + if err := ledger.Retire(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(backend.effects, []ledgerTransition{{105, true}, {105, false}}) { + t.Fatal("cleanup adopted/released the rejected key", backend.effects) + } + if collision == "preexisting" && !backend.held[38] { + t.Fatal("cleanup released an unowned key") + } + }) + } +} + +func TestRemoteKeyLedgerFailedEffectsRetainCleanup(t *testing.T) { + for _, failingDown := range []bool{true, false} { + ledger, backend := ledgerFixture(t) + if !failingDown { + applyLedgerKey(t, ledger, "a", true) + } + backend.setError = func(uint16, bool) error { return errors.New("private native failure") } + if err := ledger.Apply("a", failingDown); !errors.Is(err, ErrRemoteKeyBackend) || strings.Contains(err.Error(), "private") { + t.Fatal("uncertain native operation did not fail with a constant error") + } + if ledger.held["a"] != 38 || !ledger.stopped { + t.Fatal("failed effect lost its reserved destination") + } + if !errors.Is(ledger.Retire(), ErrRemoteKeyBackend) || ledger.held["a"] != 38 { + t.Fatal("failed cleanup forgot its retryable key") + } + backend.codes["a"] = 99 + backend.setError = nil + if err := ledger.Retire(); err != nil || len(ledger.held) != 0 || backend.held[38] { + t.Fatal("cleanup retry did not use the original destination", err) + } + if last := backend.effects[len(backend.effects)-1]; last != (ledgerTransition{38, false}) { + t.Fatal("cleanup retried a new mapping", last) + } + } +} + +func TestRemoteKeyLedgerPartialCleanupPreservesOrder(t *testing.T) { + ledger, backend := ledgerFixture(t) + for _, name := range []string{"Shift_L", "Control_R", "a", "q"} { + applyLedgerKey(t, ledger, name, true) + } + backend.setError = func(code uint16, down bool) error { + if !down && (code == 105 || code == 24) { + return errors.New("fixture uncertainty") + } + return nil + } + if !errors.Is(ledger.Retire(), ErrRemoteKeyBackend) || !reflect.DeepEqual(ledger.order, []string{"Control_R", "q"}) { + t.Fatal("partial cleanup did not retain original retry order") + } + if !reflect.DeepEqual(backend.effects[4:], []ledgerTransition{{24, false}, {38, false}, {105, false}, {50, false}}) { + t.Fatal("cleanup did not attempt every owned key in reverse down order") + } + backend.setError = nil + if err := ledger.Retire(); err != nil || !reflect.DeepEqual(backend.effects[8:], []ledgerTransition{{24, false}, {105, false}}) { + t.Fatal("retry repeated successful cleanup or lost its order", err) + } +} + +func TestRemoteKeyLedgerBoundsAndUnavailableKeys(t *testing.T) { + if _, err := NewRemoteKeyLedger(nil); !errors.Is(err, ErrRemoteKeyUnavailable) { + t.Fatal("accepted missing backend") + } + var absent *RemoteKeyLedger + if !errors.Is(absent.Apply("a", true), ErrRemoteKeyState) || !errors.Is(absent.Retire(), ErrRemoteKeyState) { + t.Fatal("nil ledger accepted work") + } + for _, name := range []string{"unknown private key", "Caps_Lock", "Num_Lock", "Scroll_Lock"} { + ledger, backend := ledgerFixture(t) + if err := ledger.Apply(name, true); !errors.Is(err, ErrRemoteKeyUnavailable) || len(backend.effects) != 0 || len(backend.resolves) != 0 { + t.Fatal("unsupported key or lock reached the native backend") + } + } + for _, code := range []uint16{0, 7, 256, 65535} { + ledger, backend := ledgerFixture(t) + backend.codes["a"] = code + if !errors.Is(ledger.Apply("a", true), ErrRemoteKeyUnavailable) || len(backend.effects) != 0 { + t.Fatal("invalid native code produced an effect") + } + } + ledger, backend := ledgerFixture(t) + for index := range RemoteHeldKeyLimit { + name := string(rune('a' + index)) + if index >= 26 { + name = string(rune('0' + index - 26)) + } + backend.codes[name] = uint16(8 + index) + applyLedgerKey(t, ledger, name, true) + } + backend.codes["6"] = 255 + if !errors.Is(ledger.Apply("6", true), ErrRemoteKeyLimit) || len(backend.effects) != RemoteHeldKeyLimit { + t.Fatal("held-key overflow produced an extra effect") + } + if err := ledger.Retire(); err != nil || len(backend.effects) != 2*RemoteHeldKeyLimit { + t.Fatal("overflow lost bounded cleanup", err) + } +} + +func TestRemoteKeyLedgerBackendFailuresArePrivate(t *testing.T) { + for _, operation := range []string{"resolve", "held"} { + ledger, backend := ledgerFixture(t) + failure := errors.New("private backend name and path") + if operation == "resolve" { + backend.resolveError = failure + } else { + backend.heldError = failure + } + if err := ledger.Apply("a", true); err == nil || strings.Contains(err.Error(), "private") || len(backend.effects) != 0 || len(ledger.held) != 0 { + t.Fatal("backend error leaked input or caused an effect") + } + } +} diff --git a/internal/input/remote_key_names.go b/internal/input/remote_key_names.go new file mode 100644 index 0000000..4b822ec --- /dev/null +++ b/internal/input/remote_key_names.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package input + +import "strconv" + +// ValidRemoteKeyName defines the shared logical-key vocabulary. It grants no +// native capability and performs no layout/keycode lookup. +func ValidRemoteKeyName(name string) bool { + if len(name) == 1 && (name[0] >= 'a' && name[0] <= 'z' || name[0] >= '0' && name[0] <= '9') { + return true + } + if len(name) >= 2 && len(name) <= 3 && name[0] == 'F' { + number, err := strconv.Atoi(name[1:]) + return err == nil && number >= 1 && number <= 24 && strconv.Itoa(number) == name[1:] + } + if len(name) == 4 && name[:3] == "KP_" && name[3] >= '0' && name[3] <= '9' { + return true + } + switch name { + case "BackSpace", "Tab", "Return", "Escape", "space", "Pause", "Print", "Menu", + "Caps_Lock", "Num_Lock", "Scroll_Lock", "Shift_L", "Shift_R", "Control_L", "Control_R", + "Alt_L", "Alt_R", "Super_L", "Super_R", "Page_Up", "Page_Down", "End", "Home", + "Left", "Up", "Right", "Down", "Insert", "Delete", + "KP_Begin", "KP_Enter", "KP_Page_Up", "KP_Page_Down", "KP_End", "KP_Home", + "KP_Left", "KP_Up", "KP_Right", "KP_Down", "KP_Insert", "KP_Delete", + "KP_Multiply", "KP_Add", "KP_Separator", "KP_Subtract", "KP_Decimal", "KP_Divide", + "semicolon", "equal", "comma", "minus", "period", "slash", "grave", "bracketleft", + "backslash", "bracketright", "apostrophe": + return true + } + return false +} diff --git a/internal/platform/xorg/remote_keys.go b/internal/platform/xorg/remote_keys.go new file mode 100644 index 0000000..e72d636 --- /dev/null +++ b/internal/platform/xorg/remote_keys.go @@ -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 +#include +#include + +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 +} diff --git a/internal/platform/xorg/remote_keys_native_test.go b/internal/platform/xorg/remote_keys_native_test.go new file mode 100644 index 0000000..6b29703 --- /dev/null +++ b/internal/platform/xorg/remote_keys_native_test.go @@ -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) +} diff --git a/internal/tentaclewire/remote_key.go b/internal/tentaclewire/remote_key.go index bed6594..581c138 100644 --- a/internal/tentaclewire/remote_key.go +++ b/internal/tentaclewire/remote_key.go @@ -6,7 +6,8 @@ package tentaclewire import ( "encoding/json" - "strconv" + + "git.stormux.org/storm/cthulhu/internal/input" ) const RemoteKeyQueueSize = 256 @@ -25,32 +26,7 @@ type RemoteKeyRequest struct { Key RemoteKey `json:"key"` } -func ValidRemoteKey(key RemoteKey) bool { - name := key.Name - if len(name) == 1 && (name[0] >= 'a' && name[0] <= 'z' || name[0] >= '0' && name[0] <= '9') { - return true - } - if len(name) >= 2 && len(name) <= 3 && name[0] == 'F' { - number, err := strconv.Atoi(name[1:]) - return err == nil && number >= 1 && number <= 24 && strconv.Itoa(number) == name[1:] - } - if len(name) == 4 && name[:3] == "KP_" && name[3] >= '0' && name[3] <= '9' { - return true - } - switch name { - case "BackSpace", "Tab", "Return", "Escape", "space", "Pause", "Print", "Menu", - "Caps_Lock", "Num_Lock", "Scroll_Lock", "Shift_L", "Shift_R", "Control_L", "Control_R", - "Alt_L", "Alt_R", "Super_L", "Super_R", "Page_Up", "Page_Down", "End", "Home", - "Left", "Up", "Right", "Down", "Insert", "Delete", - "KP_Begin", "KP_Enter", "KP_Page_Up", "KP_Page_Down", "KP_End", "KP_Home", - "KP_Left", "KP_Up", "KP_Right", "KP_Down", "KP_Insert", "KP_Delete", - "KP_Multiply", "KP_Add", "KP_Separator", "KP_Subtract", "KP_Decimal", "KP_Divide", - "semicolon", "equal", "comma", "minus", "period", "slash", "grave", "bracketleft", - "backslash", "bracketright", "apostrophe": - return true - } - return false -} +func ValidRemoteKey(key RemoteKey) bool { return input.ValidRemoteKeyName(key.Name) } func ValidRemoteKeyRequest(request RemoteKeyRequest) bool { return len(request.Lease) == 32 && request.Epoch != 0 && request.Sequence != 0 && ValidRemoteKey(request.Key) diff --git a/tests/native_remote_keys.py b/tests/native_remote_keys.py new file mode 100644 index 0000000..db7bbf7 --- /dev/null +++ b/tests/native_remote_keys.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Storm Dragon +# Authors: Storm Dragon, Angus Kola +# SPDX-License-Identifier: GPL-3.0-or-later +"""Exercise quarantined destination-key ownership on a private Xvfb server.""" +import os +from pathlib import Path +import shutil +import sys + +from native_test_session import run_private_session + + +def main(): + for program in ("xvfb-run", "dbus-run-session", "setxkbmap", "go"): + if shutil.which(program) is None: + raise RuntimeError(f"Required private-test dependency is missing: {program}") + environment = dict(os.environ, CTHULHU_NATIVE_REMOTE_KEYS="1", + GOCACHE="/tmp/cthulhu-cache", GOPROXY="off") + command = ["xvfb-run", "-a", "-s", "-screen 0 1024x768x24", "dbus-run-session", "--", + "go", "test", "-race", "./internal/platform/xorg", "-count=1", "-timeout=60s", + "-run", "^TestNativeRemoteKeyTarget", "-v"] + return run_private_session(command, env=environment, + cwd=Path(__file__).resolve().parents[1], check=True).returncode + + +if __name__ == "__main__": + sys.exit(main())