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,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
|
||||
}
|
||||
Reference in new issue
Block a user