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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user